From e28b4fe41dbaea94f2ff2b513439df2797b31d62 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Wed, 26 Aug 2026 22:18:42 +0200 Subject: [PATCH 01/34] test: syscall fault injection harness for every backend Add boost_corosio_fault_tests, a separate test target that shadows the OS entry points corosio calls and fails them on demand, so the error branches after each system call can be covered without touching the library's production symbols. Mechanism per platform: - ELF (Linux, FreeBSD; static and shared): strong extern "C" definitions in the executable forward through dlsym(RTLD_NEXT); the shared library's PLT binds to them. A startup readback proves the binding. Fortify aliases are shadowed so optimized builds reach the hooks, and a statically linked liburing falls back to the shared object for the real body. - Mach-O (macOS): the same definitions serve static builds; for shared builds the corosio dylib's import slots are rebound at startup by value match, with an independent readback. - PE (Windows): the import tables of the executable and boost_corosio.dll are patched, following ws2_32's ordinal imports; AcceptEx, ConnectEx and the Nt* entry points are reached by substituting the pointers WSAIoctl and GetProcAddress hand back. - io_uring: liburing's exported calls are shadowed, SQ exhaustion is reached by clamping the ring, and completions are rewritten in the mapped CQ ring. IOCP completions are rewritten in the GetQueuedCompletionStatus hook. Faults are armed per thread through fault_scope (nth call, error code or short count), with an opt-in process-wide arm for pool-thread and callback-thread calls, four independent arms per thread, and cqe and completion scopes for completion-side errors. Tests cover the posix-common, epoll, select, kqueue, io_uring and IOCP error branches. The suite builds and runs on every CI leg including FreeBSD; sanitizer configurations whose runtimes patch the same symbols (TSan, Windows ASan) are skipped explicitly. Line coverage with the published flags: Linux 92.0% to 93.2%, macOS 92.8% to 94.2%, Windows 92.6% to 94.5%. --- test/unit/CMakeLists.txt | 5 + test/unit/Jamfile | 10 +- test/unit/fault/CMakeLists.txt | 81 ++ test/unit/fault/Jamfile | 86 ++ test/unit/fault/epoll_faults.cpp | 248 +++++ test/unit/fault/fault.hpp | 253 +++++ test/unit/fault/fault_arm.cpp | 219 +++++ test/unit/fault/fault_posix.cpp | 998 +++++++++++++++++++ test/unit/fault/fault_slot.hpp | 152 +++ test/unit/fault/fault_test_utils.hpp | 266 +++++ test/unit/fault/fault_uring.cpp | 176 ++++ test/unit/fault/fault_win.cpp | 867 +++++++++++++++++ test/unit/fault/iocp_faults.cpp | 1197 +++++++++++++++++++++++ test/unit/fault/kqueue_faults.cpp | 412 ++++++++ test/unit/fault/posix_faults.cpp | 550 +++++++++++ test/unit/fault/reactor_faults.cpp | 17 + test/unit/fault/reactor_faults.hpp | 702 ++++++++++++++ test/unit/fault/select_faults.cpp | 217 +++++ test/unit/fault/self_test.cpp | 1343 ++++++++++++++++++++++++++ test/unit/fault/uring_faults.cpp | 545 +++++++++++ test/unit/fault/win_faults.cpp | 630 ++++++++++++ 21 files changed, 8972 insertions(+), 2 deletions(-) create mode 100644 test/unit/fault/CMakeLists.txt create mode 100644 test/unit/fault/Jamfile create mode 100644 test/unit/fault/epoll_faults.cpp create mode 100644 test/unit/fault/fault.hpp create mode 100644 test/unit/fault/fault_arm.cpp create mode 100644 test/unit/fault/fault_posix.cpp create mode 100644 test/unit/fault/fault_slot.hpp create mode 100644 test/unit/fault/fault_test_utils.hpp create mode 100644 test/unit/fault/fault_uring.cpp create mode 100644 test/unit/fault/fault_win.cpp create mode 100644 test/unit/fault/iocp_faults.cpp create mode 100644 test/unit/fault/kqueue_faults.cpp create mode 100644 test/unit/fault/posix_faults.cpp create mode 100644 test/unit/fault/reactor_faults.cpp create mode 100644 test/unit/fault/reactor_faults.hpp create mode 100644 test/unit/fault/select_faults.cpp create mode 100644 test/unit/fault/self_test.cpp create mode 100644 test/unit/fault/uring_faults.cpp create mode 100644 test/unit/fault/win_faults.cpp diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index a8fe4bbf3..208d74afe 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -10,6 +10,9 @@ file(GLOB_RECURSE PFILES CONFIGURE_DEPENDS *.cpp *.hpp) +# The fault harness shadows libc symbols; it links into its own target. +list(FILTER PFILES EXCLUDE REGEX "/fault/") + if (NOT OpenSSL_FOUND AND NOT WolfSSL_FOUND) list(FILTER PFILES EXCLUDE REGEX "tls_stream_stress\\.cpp$") endif() @@ -49,3 +52,5 @@ boost_capy_test_suite_discover_tests(boost_corosio_tests) # Add the main test target to Boost's test suite add_dependencies(tests boost_corosio_tests) + +add_subdirectory(fault) diff --git a/test/unit/Jamfile b/test/unit/Jamfile index 6bc31a5ea..261697224 100644 --- a/test/unit/Jamfile +++ b/test/unit/Jamfile @@ -33,12 +33,18 @@ project boost/corosio/test/unit gcc:-Wno-maybe-uninitialized ; -# Non-TLS tests (recurses into test/, native/, etc.) -for local f in [ glob-tree-ex . : *.cpp : openssl_stream.cpp wolfssl_stream.cpp cross_ssl_stream.cpp tls_stream.cpp tls_stream_stress.cpp iocp_shutdown.cpp iocp_error_map.cpp openssl_engine.cpp wolfssl_engine.cpp cross_engine.cpp ] +# Non-TLS tests (recurses into test/, native/, etc.). `fault` excludes +# the whole directory rather than its sources one by one: glob-tree-ex +# applies the exclusion patterns to directory entries too and does not +# descend into a match. Those targets need the hook translation unit +# linked in, which only fault/Jamfile does. +for local f in [ glob-tree-ex . : *.cpp : openssl_stream.cpp wolfssl_stream.cpp cross_ssl_stream.cpp tls_stream.cpp tls_stream_stress.cpp iocp_shutdown.cpp iocp_error_map.cpp openssl_engine.cpp wolfssl_engine.cpp cross_engine.cpp fault ] { run $(f) ; } +build-project fault ; + # IOCP-specific native tests (Windows host only; skip on other hosts to # avoid triggering a cross-compile attempt). if [ os.name ] = NT diff --git a/test/unit/fault/CMakeLists.txt b/test/unit/fault/CMakeLists.txt new file mode 100644 index 000000000..59af4c263 --- /dev/null +++ b/test/unit/fault/CMakeLists.txt @@ -0,0 +1,81 @@ +# +# 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 +# + +# The POSIX shadows are written against glibc, Darwin libc and the +# FreeBSD libc and the Windows hooks against the PE import table; every +# other platform spells its entry points differently and has no +# coverage here. +if(NOT (WIN32 OR APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Linux" + OR CMAKE_SYSTEM_NAME STREQUAL "FreeBSD")) + return() +endif() + +# TSan's runtime defines the same libc symbols the harness shadows and +# models fd ordering inside them, so the interposers never see the call. +# TSan reaches this tree only through CMAKE_CXX_FLAGS: there is no +# sanitizer option of our own to key off. +if(CMAKE_CXX_FLAGS MATCHES "-fsanitize=thread") + message(STATUS "corosio: fault tests skipped (ThreadSanitizer)") + return() +endif() + +# Windows ASan interposes by patching the very import thunks this +# harness owns, so the two cannot both hold a symbol. The Jamfile gates +# the same configuration off. +if(WIN32 AND CMAKE_CXX_FLAGS MATCHES "fsanitize=address") + message(STATUS "corosio: fault tests skipped (AddressSanitizer)") + return() +endif() + +file(GLOB FAULT_FILES CONFIGURE_DEPENDS *.cpp *.hpp) +if(WIN32) + # The POSIX sources include and friends unconditionally, + # so on Windows the list is named rather than filtered. + list(FILTER FAULT_FILES EXCLUDE REGEX "\\.cpp$") + list(APPEND FAULT_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/fault_arm.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/fault_win.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/self_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/win_faults.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/iocp_faults.cpp) +else() + list(FILTER FAULT_FILES EXCLUDE REGEX "fault_win\\.cpp$") + if(NOT BOOST_COROSIO_HAVE_LIBURING) + list(FILTER FAULT_FILES EXCLUDE REGEX "uring") + endif() +endif() +# No per-platform filter beyond those: every backend suite is gated on +# its own BOOST_COROSIO_HAS_* macro, so the ones that do not apply here +# compile away to an empty translation unit. +list(APPEND FAULT_FILES CMakeLists.txt Jamfile) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} PREFIX "" FILES ${FAULT_FILES}) + +add_executable(boost_corosio_fault_tests ${FAULT_FILES}) +target_link_libraries(boost_corosio_fault_tests PRIVATE + Boost::capy_test_suite_main + Boost::corosio) +target_include_directories(boost_corosio_fault_tests PRIVATE + . .. ../../../ ../../../src/corosio) + +if(NOT WIN32) + target_link_libraries(boost_corosio_fault_tests PRIVATE ${CMAKE_DL_LIBS}) + # Interposition only works if the shadows land in the executable's + # dynamic symbol table. A superproject that builds everything with + # hidden visibility would strip them, and the readback then refuses + # to run rather than reporting faults that never fire. Windows + # rewrites import thunks instead and needs no exported shadows. + set_target_properties(boost_corosio_fault_tests PROPERTIES + C_VISIBILITY_PRESET default + CXX_VISIBILITY_PRESET default + VISIBILITY_INLINES_HIDDEN OFF + ENABLE_EXPORTS ON) +endif() + +boost_capy_test_suite_discover_tests(boost_corosio_fault_tests) +add_dependencies(tests boost_corosio_fault_tests) diff --git a/test/unit/fault/Jamfile b/test/unit/fault/Jamfile new file mode 100644 index 000000000..57c5b9666 --- /dev/null +++ b/test/unit/fault/Jamfile @@ -0,0 +1,86 @@ +# +# 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 +# + +import os ; +import testing ; + +project boost/corosio/test/unit/fault + : requirements + /boost/corosio//boost_corosio + ../../../../capy/extra/test_suite/test_main.cpp + ../../../../capy/extra/test_suite/test_suite.cpp + ../../../../capy/extra/test_suite + . + .. + ../../.. + ../../../src/corosio + windows:_WIN32_WINNT=0x0602 + extra + on + gcc:-Wno-maybe-uninitialized + linux:-ldl + # Interposition only works if the shadows land in the executable's + # dynamic symbol table; Boost builds tests with hidden visibility. + # so the flag stays on these targets instead of + # propagating into a private rebuild of corosio and capy. + global + # Static sanitizer runtimes define the same libc symbols the + # harness shadows; TSan also models fd ordering in them. + on:no + norecover:no + clang,on:no + clang,norecover:no + # Windows ASan interposes by patching the very import thunks this + # harness owns, so the two cannot both hold a symbol. + msvc,on:no + msvc,norecover:no + clang-win,on:no + clang-win,norecover:no + ; + +# The POSIX shadows are written against glibc, Darwin libc and the +# FreeBSD libc and the Windows hooks against the PE import table, so +# nothing is built anywhere else; the CMake side gates the same way. +# Darwin and FreeBSD drop only epoll_faults.cpp and uring_faults.cpp, +# which have no counterpart there. +# fault_arm.cpp holds the arm model every hook translation unit shares. +local hooks = fault_arm.cpp fault_posix.cpp ; +local tests ; +local uring-hook ; +if [ os.name ] = NT +{ + hooks = fault_arm.cpp fault_win.cpp ; + tests = self_test.cpp win_faults.cpp iocp_faults.cpp ; +} +else if [ os.name ] = MACOSX +{ + tests = self_test.cpp posix_faults.cpp select_faults.cpp + kqueue_faults.cpp reactor_faults.cpp ; +} +else if [ os.name ] = FREEBSD +{ + tests = self_test.cpp posix_faults.cpp select_faults.cpp + kqueue_faults.cpp reactor_faults.cpp ; +} +else if [ os.name ] = LINUX +{ + tests = self_test.cpp posix_faults.cpp select_faults.cpp + epoll_faults.cpp reactor_faults.cpp uring_faults.cpp ; + # The io_uring hook includes unconditionally, so it can + # only build where the library's own probe found liburing. Without + # it uring_faults.cpp compiles away behind BOOST_COROSIO_HAS_IO_URING + # and the program still runs; CMake drops that source instead. + uring-hook = [ check-target-builds /boost/corosio//has_liburing + : fault_uring.cpp : ] ; +} + +for local f in $(tests) +{ + run $(f) $(hooks) : : : $(uring-hook) ; +} diff --git a/test/unit/fault/epoll_faults.cpp b/test/unit/fault/epoll_faults.cpp new file mode 100644 index 000000000..37c8f98dd --- /dev/null +++ b/test/unit/fault/epoll_faults.cpp @@ -0,0 +1,248 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_EPOLL + +namespace boost::corosio::test::fault { + +namespace { + +endpoint loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +} // namespace + +struct epoll_faults +{ + void testConstructorFails() + { + auto expect_throw = [](sys s, unsigned nth, int err, std::errc code) + { + fault_scope f(s, err, nth); + expect_system_error([&]{ io_context ioc(epoll); }, code); + BOOST_TEST(f.fired()); + }; + expect_throw(sys::epoll_create1, 1, EMFILE, + std::errc::too_many_files_open); + expect_throw(sys::eventfd, 1, EMFILE, + std::errc::too_many_files_open); + expect_throw(sys::timerfd_create, 1, EMFILE, + std::errc::too_many_files_open); + // 1 registers the eventfd, 2 the timerfd. + expect_throw(sys::epoll_ctl, 1, ENOMEM, + std::errc::not_enough_memory); + expect_throw(sys::epoll_ctl, 2, ENOMEM, + std::errc::not_enough_memory); + } + + void testOpenFails() + { + io_context ioc(epoll); + { + tcp_socket s(ioc); + fault_scope f(sys::socket, EMFILE); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + } + { + // The socket already exists when registration fails, so the + // failure path owns closing it. + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::epoll_ctl, ENOMEM); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_enough_memory); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testAcceptorRegisterFails() + { + io_context ioc(epoll); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(loopback())); + // listen is where an acceptor first reaches register_descriptor. + fault_scope f(sys::epoll_ctl, ENOMEM); + BOOST_TEST(acc.listen() == std::errc::not_enough_memory); + BOOST_TEST(f.fired()); + // Not latched: the descriptor is still unregistered, so a + // second listen registers it. + BOOST_TEST(!acc.listen()); + } + + void testAcceptFails() + { + io_context ioc(epoll); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + std::error_code aec, aec2; + auto body = [&]() -> capy::task<> + { + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // EINTR is retried inside accept_policy, then the real + // accept4 succeeds. + fault_scope f(sys::accept4, EINTR); + auto [ec] = co_await acc.accept(server); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + } + server.close(); + client.close(); + tcp_socket client2(ioc); + { + auto [ec] = co_await client2.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + fault_scope f(sys::accept4, ECONNABORTED); + auto [ec] = co_await acc.accept(server); + aec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec] = co_await acc.accept(server); + BOOST_TEST(!ec); + } + server.close(); + client2.close(); + tcp_socket client3(ioc); + { + auto [ec] = co_await client3.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // The accepted fd fails to register: the impl is + // destroyed, which closes it, and the error is reported. + int before = open_fds(); + fault_scope f(sys::epoll_ctl, ENOMEM); + auto [ec] = co_await acc.accept(server); + aec2 = ec; + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + client3.close(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(aec == std::errc::connection_aborted); + BOOST_TEST(aec2 == std::errc::not_enough_memory); + BOOST_TEST(!server.is_open()); + } + + void testRunLoopFaults() + { + { + io_context ioc(epoll); + fault_scope f(sys::epoll_wait, EINTR); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(done); + } + { + io_context ioc(epoll); + fault_scope f(sys::epoll_wait, EBADF); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + } + { + io_context ioc(epoll); + fault_scope f(sys::timerfd_settime, EINVAL); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } + } + + void testSignalReaderRegisterFails() + { + in_child([]{ + io_context ioc(epoll); + signal_set ss(ioc); + std::error_code ec; + bool fired = false; + { + fault_scope f(sys::epoll_ctl, ENOMEM); + ec = ss.add(SIGUSR2); + fired = f.fired(); + } + // Not latched: the next add retries the registration. + return fired && ec == std::errc::not_enough_memory && + !ss.add(SIGUSR2) && !ss.clear(); + }); + } + + void run() + { + if(skip_under_valgrind()) + return; + testConstructorFails(); + testOpenFails(); + testAcceptorRegisterFails(); + testAcceptFails(); + testRunLoopFaults(); + testSignalReaderRegisterFails(); + } +}; + +TEST_SUITE(epoll_faults, "boost.corosio.fault.epoll"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp new file mode 100644 index 000000000..2fbaacb8b --- /dev/null +++ b/test/unit/fault/fault.hpp @@ -0,0 +1,253 @@ +// +// 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_TEST_FAULT_HPP +#define BOOST_COROSIO_TEST_FAULT_HPP + +#include + +namespace boost::corosio::test::fault { + +/** OS entry points the harness can fail. + + Names equal the libc / liburing / Win32 symbol. Every enumerator + exists on every platform so a portable test can name one behind a + `#if`; arming a symbol the running platform has no shadow for + simply never fires. The Berkeley-socket names (`socket`, `bind`, + `listen`, `accept`, `connect`, `shutdown`, `getsockname`, + `getpeername`, `getsockopt`, `setsockopt`, `send`, `recv`) name the + ws2_32 entry point of the same name on Windows. The + `uring_sqe_full` enumerator is not a symbol: arming it clamps the + next ring to one SQE and turns `io_uring_submit` into a no-op so + `io_uring_get_sqe` returns null on the second acquisition. +*/ +enum class sys +{ + socket, socketpair, bind, listen, accept, accept4, connect, + getsockname, getpeername, getsockopt, setsockopt, shutdown, close, + read, write, writev, readv, preadv, pwritev, recv, send, recvmsg, + sendmsg, + poll, pipe, fcntl, ioctl, open, fstat, lseek, ftruncate, fsync, + fdatasync, posix_fadvise, unlink, sigaction, getaddrinfo, + freeaddrinfo, getnameinfo, gethostname, + epoll_create1, epoll_ctl, epoll_wait, eventfd, timerfd_create, + timerfd_settime, select, kqueue, kevent, + io_uring_queue_init_params, io_uring_queue_exit, io_uring_submit, + io_uring_submit_and_wait_timeout, io_uring_submit_and_get_events, + io_uring_wait_cqe_timeout, uring_sqe_full, + WSASocketW, WSAConnect, WSARecv, WSASend, WSARecvFrom, WSASendTo, + WSAPoll, WSAIoctl, WSAStartup, WSACleanup, closesocket, ioctlsocket, + GetAddrInfoExW, GetAddrInfoExCancel, FreeAddrInfoExW, GetNameInfoW, + CreateIoCompletionPort, GetQueuedCompletionStatus, + PostQueuedCompletionStatus, CancelIoEx, CloseHandle, CreateFileW, + ReadFile, WriteFile, SetFilePointerEx, GetFileSizeEx, SetEndOfFile, + FlushFileBuffers, DeleteFileA, CreateWaitableTimerW, SetWaitableTimer, + WaitForSingleObject, GetComputerNameExW, GetModuleHandleA, + GetModuleHandleW, GetProcAddress, MultiByteToWideChar, + WideCharToMultiByte, signal, + // Reached through a pointer the OS hands out rather than through an + // import: the WSAIoctl and GetProcAddress hooks substitute a wrapper + // for the pointer the library caches. + AcceptEx, ConnectEx, NtSetInformationFile, NtFlushBuffersFileEx, + count_ +}; + +/** Tag selecting the process-wide arm mode. + + A thread-local arm watching the same symbol shadows the + process-wide one for the thread that holds it, in either mode: that + thread's calls are counted and claimed by its own arm and never + reach the process-wide arm, even after the thread-local one has + fired or when its `nth` is out of reach. + + @see fault_scope +*/ +inline constexpr struct any_thread_t {} any_thread{}; + +/** Fail one OS call, by default on the current thread. + + While the scope is alive the `nth` call to `which` returns its + documented failure value with `errno` set to `err` (liburing + shadows return `-err`; on Windows `err` is a `WSA*`/`ERROR_*` code + published through `SetLastError`, and the handful of entry points + that report through their return value return it directly). Calls + before the nth and all calls after it forward to the real + function. The arm is thread-local unless + the scope is created with the `any_thread` overload, so by + default other threads never observe the fault. + + Up to four thread-local scopes may be alive at once, each with its + own counter: a deferred-path test parks an operation with one and + fails its reactor retry with another. Every live arm watching a + symbol counts each call to it, so two arms on the same symbol are + distinguished by `nth`. + + @par Preconditions + Fewer than four `fault_scope` objects are alive on this thread; + a fifth aborts. A scope must be constructed and destroyed on the + same thread, since it owns one of that thread's arms by index: a + scope held across a `co_await` therefore requires an + `io_context` run by a single thread, or the destructor releases + an arm belonging to some other thread. A process-wide scope has + its own one-at-a-time rule, described on the `any_thread` + overload. + + @par Example + @code + fault_scope f(sys::epoll_ctl, EPERM); + tcp_socket s(ioc); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::operation_not_permitted); + @endcode +*/ +class fault_scope +{ +public: + /// Disarm, even if the fault never fired. + ~fault_scope(); + + /// Arm `which` to fail with `err` on its `nth` call. + fault_scope(sys which, int err, unsigned nth = 1); + + /** Arm `which` on every thread rather than just this one. + + The library runs file I/O and name resolution on a thread + pool, where the thread-local arms are never consulted. This + overload publishes the arm process-wide; a thread with an arm + of its own watching `which` still uses that one. + + @par Preconditions + No other process-wide scope is alive; nesting aborts. The test + must keep at most one call to `which` in flight at a time, or + the shared counter races. + */ + fault_scope(sys which, int err, unsigned nth, any_thread_t); + + /** Create a scope that shortens the `nth` call instead of failing it. + + For the byte-moving calls (`read`, `write`, `writev`, `readv`, + `preadv`, `pwritev`, `recv`, `send`, `recvmsg`, `sendmsg`) the real + function is invoked with its length clamped to `count`, so + bytes genuinely move. `count == 0` forwards nothing and returns + 0, which reads as EOF on the read side. + */ + static fault_scope returning(sys which, std::size_t count, + unsigned nth = 1); + + /** Create a `returning` scope armed on every thread. + + @par Preconditions + No other process-wide scope is alive, and at most one call to + `which` is in flight at a time. + */ + static fault_scope returning_any_thread(sys which, std::size_t count, + unsigned nth = 1); + + /// Return true once the armed call has been intercepted. + bool fired() const noexcept; + + fault_scope(fault_scope const&) = delete; + fault_scope& operator=(fault_scope const&) = delete; + +private: + struct short_tag {}; + fault_scope(short_tag, sys which, std::size_t count, unsigned nth, + bool global); + + bool global_ = false; + // Index of the claimed thread-local arm; -1 for a process-wide scope. + int idx_ = -1; +}; + +/** Rewrite one io_uring completion before corosio sees it. + + Matches the first unsubmitted SQE whose `fd` and `opcode` + (`IORING_OP_*`) equal the arguments, remembers its `user_data`, + and overwrites `res` on the CQE carrying that `user_data` when it + becomes visible. Only meaningful with the io_uring backend; the + scope is inert on the reactor backends. +*/ +class cqe_fault_scope +{ +public: + /// Destroy the scope, releasing the CQE arm it claimed. + ~cqe_fault_scope(); + + /** Construct a scope that rewrites the matched CQE's `res`. + + @param fd The descriptor the SQE was prepared on. + @param opcode The `IORING_OP_*` the SQE carries. + @param res The value to write into the CQE's `res`. + */ + cqe_fault_scope(int fd, int opcode, int res); + + /// Return true once a CQE has been rewritten. + bool fired() const noexcept; + + cqe_fault_scope(cqe_fault_scope const&) = delete; + cqe_fault_scope& operator=(cqe_fault_scope const&) = delete; +}; + +/** Fail one IOCP completion before corosio sees it. + + The `GetQueuedCompletionStatus` hook forwards, and for the `nth` + dequeue that yields a non-null `OVERLAPPED` reports failure with + `GetLastError()` set to `err`. That is the one way to reach the + error branches of the completion handlers: the kernel result of an + overlapped operation cannot be armed at the call that started it. + Windows only; on other platforms nothing defines this scope. + + @par Preconditions + No other completion fault is armed on this thread. +*/ +class completion_fault_scope +{ +public: + /// Destroy the scope, disarming the completion fault. + ~completion_fault_scope(); + + /** Construct a scope that fails one dequeued completion. + + @param err The Win32 error the dequeue reports. + @param nth Which completion carrying an `OVERLAPPED` to fail. + */ + completion_fault_scope(unsigned long err, unsigned nth = 1); + + /// Return true once a completion has been failed. + bool fired() const noexcept; + + completion_fault_scope(completion_fault_scope const&) = delete; + completion_fault_scope& operator=(completion_fault_scope const&) = delete; +}; + +/** Return true if arming `which` can still fire. + + Windows reaches its entry points through an import table, and what + a program imports is decided when it is linked: a name no module + references has no thunk to patch and no arm on it will ever fire. + The harness reports those at startup; a test asks here rather than + driving a hook that cannot fire. Windows only; on other platforms + nothing defines this. + + @param which The entry point to ask about. The four reached through + a pointer the OS hands out (`AcceptEx`, `ConnectEx`, + `NtSetInformationFile`, `NtFlushBuffersFileEx`) answer for the + hook that substitutes that pointer. + + @return `true` if a hook for `which` is installed in some module. +*/ +bool hook_is_live(sys which) noexcept; + +/// Return true if the executable links corosio as a shared library. +bool corosio_is_shared() noexcept; + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/fault_arm.cpp b/test/unit/fault/fault_arm.cpp new file mode 100644 index 000000000..58494309d --- /dev/null +++ b/test/unit/fault/fault_arm.cpp @@ -0,0 +1,219 @@ +// +// 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 +// + +// The arm model is the same wherever the hooks are: only the way a +// failing call publishes its error code differs, and that is +// publish_error. Keeping this one copy is what stops the POSIX and +// Windows harnesses from drifting apart on rules a test relies on -- +// which arm claims a call, when an arm stays reserved, what a fifth +// scope does. + +#include "fault.hpp" +#include "fault_slot.hpp" + +#include +#include +#include + +namespace boost::corosio::test::fault { + +thread_local arm_set tls_arms; +std::atomic global_slot{nullptr}; + +[[noreturn]] void die(char const* msg) noexcept +{ + std::fputs(msg, stderr); + std::fputc('\n', stderr); + std::abort(); +} + +namespace { + +// Storage behind the process-wide arm; only one scope may own it. +slot global_storage; + +// Single place that claims an arm, shared by every constructor. +// Returns the arm index, or -1 for the process-wide slot. +int arm(slot desired, bool global) noexcept +{ + desired.owned = true; + desired.armed = true; + if(!global) + { + for(int i = 0; i < arm_set::max_arms; ++i) + { + if(tls_arms.arms[i].owned) + continue; + tls_arms.arms[i] = desired; + return i; + } + char msg[96]; + std::snprintf(msg, sizeof(msg), + "fault_scope: all %d fault arms are in use on this thread", + arm_set::max_arms); + die(msg); + } + if(global_slot.load(std::memory_order_acquire)) + die("fault_scope: a process-wide fault is already armed"); + global_storage = desired; + global_slot.store(&global_storage, std::memory_order_release); + return -1; +} + +} // namespace + +slot* armed_arm(sys which) noexcept +{ + for(auto& s : tls_arms.arms) + { + if(s.armed && s.which == which) + return &s; + } + return nullptr; +} + +namespace { + +// Every arm watching `which` counts the call, so two arms on the same +// symbol can claim different occurrences of it. Only one arm may claim +// a given call: the lowest-indexed one whose count just reached its +// nth. A later arm that reaches its nth on that same call has its +// count rolled back instead of being marked fired, so it stays armed +// and claims the next call rather than being spent on a fault that was +// never delivered. Returns null when the call must forward, and reports +// through `matched` whether this thread watches `which` at all, which +// is what keeps the process-wide slot a fallback rather than a second +// chance. +slot* claim_arm(sys which, bool short_mode, bool& matched) noexcept +{ + slot* won = nullptr; + for(auto& s : tls_arms.arms) + { + if(!s.armed || s.which != which) + continue; + matched = true; + if(s.short_mode != short_mode) + continue; + if(++s.seen != s.nth) + continue; + if(won) + { + --s.seen; + continue; + } + s.armed = false; + s.fired = true; + won = &s; + } + return won; +} + +// The process-wide slot, if it is armed for `which` in this mode. +slot* claim_global(sys which, bool short_mode) noexcept +{ + slot* p = global_slot.load(std::memory_order_acquire); + if(!p) + return nullptr; + auto& s = *p; + if(!s.armed || s.which != which || s.short_mode != short_mode) + return nullptr; + if(++s.seen != s.nth) + return nullptr; + s.armed = false; + s.fired = true; + return &s; +} + +} // namespace + +bool should_fail(sys which) noexcept +{ + bool matched = false; + slot* s = claim_arm(which, false, matched); + if(!s && !matched) + s = claim_global(which, false); + if(!s) + return false; + publish_error(s->err); + return true; +} + +bool should_shorten(sys which, std::size_t& count) noexcept +{ + bool matched = false; + slot* s = claim_arm(which, true, matched); + if(!s && !matched) + s = claim_global(which, true); + if(!s) + return false; + count = s->count; + return true; +} + +fault_scope::fault_scope(sys which, int err, unsigned nth) +{ + slot s; + s.which = which; + s.err = err; + s.nth = nth; + idx_ = arm(s, false); +} + +fault_scope::fault_scope(sys which, int err, unsigned nth, any_thread_t) + : global_(true) +{ + slot s; + s.which = which; + s.err = err; + s.nth = nth; + idx_ = arm(s, true); +} + +fault_scope::fault_scope(short_tag, sys which, std::size_t count, + unsigned nth, bool global) + : global_(global) +{ + slot s; + s.which = which; + s.count = count; + s.short_mode = true; + s.nth = nth; + idx_ = arm(s, global); +} + +fault_scope fault_scope::returning(sys which, std::size_t count, unsigned nth) +{ + return fault_scope(short_tag{}, which, count, nth, false); +} + +fault_scope fault_scope::returning_any_thread(sys which, std::size_t count, + unsigned nth) +{ + return fault_scope(short_tag{}, which, count, nth, true); +} + +fault_scope::~fault_scope() +{ + if(global_) + { + global_slot.store(nullptr, std::memory_order_release); + global_storage.armed = false; + global_storage.owned = false; + return; + } + tls_arms.arms[idx_].armed = false; + tls_arms.arms[idx_].owned = false; +} + +bool fault_scope::fired() const noexcept +{ + return global_ ? global_storage.fired : tls_arms.arms[idx_].fired; +} + +} // boost::corosio::test::fault diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp new file mode 100644 index 000000000..ba7d272cd --- /dev/null +++ b/test/unit/fault/fault_posix.cpp @@ -0,0 +1,998 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_slot.hpp" + +#include +#include + +#if BOOST_COROSIO_HAVE_LIBURING +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#endif + +#if defined(__APPLE__) || defined(__FreeBSD__) +#include +#endif + +#if defined(__APPLE__) +#include +#include +#include +#include +#endif + +// Linux-only open flag; folding it to zero keeps the mode test in the +// `open` shadow one expression on every platform. +#if !defined(O_TMPFILE) +#define O_TMPFILE 0 +#endif + +namespace boost::corosio::test::fault { + +thread_local cqe_slot tls_cqe; + +// The arm machinery lives in fault_arm.cpp; this is the one piece of +// it that has to know what platform it is on. +void publish_error(int err) noexcept +{ + errno = err; +} + +void* real_symbol(char const* name) noexcept +{ + void* p = ::dlsym(RTLD_NEXT, name); + if(!p) + { + char msg[160]; + std::snprintf(msg, sizeof(msg), + "fault harness: %s has no implementation behind the shadow", + name); + die(msg); + } + return p; +} + +namespace { + +// Copy the prefix of `in` holding at most `count` bytes into `out`. +// Returns the new iovec count. Corosio never passes more than a +// handful of buffers; 64 is a hard ceiling checked at runtime. +int truncate_iov(iovec const* in, int n, std::size_t count, iovec* out) noexcept +{ + if(n > 64) + die("fault harness: iovec count exceeds 64"); + int m = 0; + for(; m < n && count > 0; ++m) + { + out[m] = in[m]; + if(out[m].iov_len > count) + out[m].iov_len = count; + count -= out[m].iov_len; + } + return m; +} + +} // namespace + +cqe_fault_scope::cqe_fault_scope(int fd, int opcode, int res) +{ + claim_completion_slot(tls_cqe, + "cqe_fault_scope: a completion fault is already armed on this thread"); + tls_cqe.fd = fd; + tls_cqe.opcode = opcode; + tls_cqe.res = res; +} + +cqe_fault_scope::~cqe_fault_scope() +{ + release_completion_slot(tls_cqe); +} + +bool cqe_fault_scope::fired() const noexcept +{ + return tls_cqe.fired; +} + +} // boost::corosio::test::fault + +using namespace boost::corosio::test::fault; + +// Each shadow resolves the real function on first use. dlsym may +// allocate, which is fine: malloc is not shadowed. +#define COROSIO_FAULT_REAL(name, sig) \ + static auto const real = reinterpret_cast(real_symbol(#name)) + +// glibc marks part of the census __THROW, which C++ reads as noexcept; +// Darwin's headers carry no exception specification at all and a +// redeclaration that adds one is ill-formed, so the spec is per-OS. +#if defined(__linux__) +#define COROSIO_FAULT_NOTHROW noexcept +#else +#define COROSIO_FAULT_NOTHROW +#endif + +extern "C" int socket(int domain, int type, int protocol) COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(socket, int(*)(int, int, int)); + if(should_fail(sys::socket)) + return -1; + return real(domain, type, protocol); +} + +// glibc marks part of the census __THROW and leaves the rest with no +// exception spec; the shadow has to match exactly or -Werror rejects +// the redeclaration, so there are two macros rather than one. On +// Darwin COROSIO_FAULT_NOTHROW is empty and the two coincide, since no +// entry point there carries a specification to match. +#define COROSIO_FAULT_HOOK(name, ret, failval, params, args) \ + extern "C" ret name params \ + { \ + COROSIO_FAULT_REAL(name, ret(*)params); \ + if(should_fail(sys::name)) \ + return failval; \ + return real args; \ + } + +#define COROSIO_FAULT_HOOK_NX(name, ret, failval, params, args) \ + extern "C" ret name params COROSIO_FAULT_NOTHROW \ + { \ + COROSIO_FAULT_REAL(name, ret(*)params); \ + if(should_fail(sys::name)) \ + return failval; \ + return real args; \ + } + +COROSIO_FAULT_HOOK_NX(socketpair, int, -1, (int d, int t, int p, int* sv), (d, t, p, sv)) +COROSIO_FAULT_HOOK_NX(bind, int, -1, (int fd, sockaddr const* a, socklen_t l), (fd, a, l)) +COROSIO_FAULT_HOOK_NX(listen, int, -1, (int fd, int n), (fd, n)) +COROSIO_FAULT_HOOK(accept, int, -1, (int fd, sockaddr* a, socklen_t* l), (fd, a, l)) +COROSIO_FAULT_HOOK(connect, int, -1, (int fd, sockaddr const* a, socklen_t l), (fd, a, l)) +COROSIO_FAULT_HOOK_NX(getsockname, int, -1, (int fd, sockaddr* a, socklen_t* l), (fd, a, l)) +COROSIO_FAULT_HOOK_NX(getpeername, int, -1, (int fd, sockaddr* a, socklen_t* l), (fd, a, l)) +COROSIO_FAULT_HOOK_NX(getsockopt, int, -1, (int fd, int lv, int on, void* v, socklen_t* l), (fd, lv, on, v, l)) +COROSIO_FAULT_HOOK_NX(setsockopt, int, -1, (int fd, int lv, int on, void const* v, socklen_t l), (fd, lv, on, v, l)) +COROSIO_FAULT_HOOK_NX(shutdown, int, -1, (int fd, int how), (fd, how)) +COROSIO_FAULT_HOOK(close, int, -1, (int fd), (fd)) +COROSIO_FAULT_HOOK(poll, int, -1, (pollfd* p, nfds_t n, int t), (p, n, t)) +COROSIO_FAULT_HOOK_NX(pipe, int, -1, (int* p), (p)) +COROSIO_FAULT_HOOK_NX(fstat, int, -1, (int fd, struct stat* st), (fd, st)) +COROSIO_FAULT_HOOK_NX(lseek, off_t, -1, (int fd, off_t off, int wh), (fd, off, wh)) +COROSIO_FAULT_HOOK_NX(ftruncate, int, -1, (int fd, off_t len), (fd, len)) +COROSIO_FAULT_HOOK(fsync, int, -1, (int fd), (fd)) +COROSIO_FAULT_HOOK_NX(unlink, int, -1, (char const* p), (p)) +COROSIO_FAULT_HOOK_NX(sigaction, int, -1, (int sig, struct sigaction const* a, struct sigaction* o), (sig, a, o)) +COROSIO_FAULT_HOOK_NX(gethostname, int, -1, (char* n, size_t l), (n, l)) + +// Linux and FreeBSD both publish these; Darwin has neither, and +// sync_data() lowers to fsync there instead. +#if defined(__linux__) || defined(__FreeBSD__) +COROSIO_FAULT_HOOK(fdatasync, int, -1, (int fd), (fd)) + +// Return the armed errno directly: this reports failure through the +// return value, not through -1 + errno. +extern "C" int posix_fadvise(int fd, off_t off, off_t len, int advice) + COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(posix_fadvise, int(*)(int, off_t, off_t, int)); + if(should_fail(sys::posix_fadvise)) + return errno; + return real(fd, off, len, advice); +} +#endif + +#if defined(__linux__) +COROSIO_FAULT_HOOK(accept4, int, -1, (int fd, sockaddr* a, socklen_t* l, int f), (fd, a, l, f)) +COROSIO_FAULT_HOOK_NX(epoll_create1, int, -1, (int f), (f)) +COROSIO_FAULT_HOOK_NX(epoll_ctl, int, -1, (int ep, int op, int fd, epoll_event* ev), (ep, op, fd, ev)) +COROSIO_FAULT_HOOK(epoll_wait, int, -1, (int ep, epoll_event* ev, int n, int t), (ep, ev, n, t)) +COROSIO_FAULT_HOOK_NX(eventfd, int, -1, (unsigned v, int f), (v, f)) +COROSIO_FAULT_HOOK_NX(timerfd_create, int, -1, (int c, int f), (c, f)) +COROSIO_FAULT_HOOK_NX(timerfd_settime, int, -1, (int fd, int f, itimerspec const* n, itimerspec* o), (fd, f, n, o)) +#endif + +#if defined(__APPLE__) || defined(__FreeBSD__) +COROSIO_FAULT_HOOK(kqueue, int, -1, (), ()) +COROSIO_FAULT_HOOK(kevent, int, -1, + (int kq, struct kevent const* ch, int nch, struct kevent* ev, int nev, + timespec const* ts), + (kq, ch, nch, ev, nev, ts)) +#endif + +#if defined(__APPLE__) +// Darwin's spells select as `select$DARWIN_EXTSN` under +// _DARWIN_C_SOURCE and plain `_select` otherwise, and each corosio +// translation unit picks its spelling independently of this one. +// Defining both by asm label shadows the call either way; the plain +// COROSIO_FAULT_HOOK cannot, because the header's own asm label would +// rename it to whichever single spelling this file happens to see. +// The library references the plain spelling today, so the suffixed one +// is a census alias rather than a symbol anything binds to. +extern "C" { +int corosio_fault_select(int, fd_set*, fd_set*, fd_set*, timeval*) + __asm__("_select"); +int corosio_fault_select_extsn(int, fd_set*, fd_set*, fd_set*, timeval*) + __asm__("_select$DARWIN_EXTSN"); +} + +namespace { + +using select_fn = int(*)(int, fd_set*, fd_set*, fd_set*, timeval*); + +// libSystem exports both spellings, but only the plain one is +// guaranteed; fall back so a missing alias cannot leave `real` null. +select_fn real_select(char const* name) noexcept +{ + auto p = reinterpret_cast(::dlsym(RTLD_NEXT, name)); + if(!p) + p = reinterpret_cast(real_symbol("select")); + return p; +} + +} // namespace + +extern "C" int corosio_fault_select(int n, fd_set* r, fd_set* w, fd_set* e, + timeval* t) +{ + static auto const real = real_select("select"); + if(should_fail(sys::select)) + return -1; + return real(n, r, w, e, t); +} + +extern "C" int corosio_fault_select_extsn(int n, fd_set* r, fd_set* w, + fd_set* e, timeval* t) +{ + static auto const real = real_select("select$DARWIN_EXTSN"); + if(should_fail(sys::select)) + return -1; + return real(n, r, w, e, t); +} +#else +COROSIO_FAULT_HOOK(select, int, -1, (int n, fd_set* r, fd_set* w, fd_set* e, timeval* t), (n, r, w, e, t)) +#endif + +// Both report failure through the return value, not -1 + errno. +extern "C" int getaddrinfo(char const* node, char const* service, + addrinfo const* hints, addrinfo** res) +{ + COROSIO_FAULT_REAL(getaddrinfo, int(*)(char const*, char const*, addrinfo const*, addrinfo**)); + if(should_fail(sys::getaddrinfo)) + return errno; + return real(node, service, hints, res); +} + +// No failure mode of its own: swallowing the release would leak the +// list the lookup allocated. The arm still counts the call, which is +// what a census of the resolver's teardown path needs. +extern "C" void freeaddrinfo(addrinfo* ai) COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(freeaddrinfo, void(*)(addrinfo*)); + std::ignore = should_fail(sys::freeaddrinfo); + real(ai); +} + +// FreeBSD sizes the host and service buffers with size_t where glibc +// and Darwin spell them socklen_t; the shadow has to match its own +// header exactly or the redeclaration is rejected. +#if defined(__FreeBSD__) +#define COROSIO_FAULT_NI_LEN size_t +#else +#define COROSIO_FAULT_NI_LEN socklen_t +#endif + +extern "C" int getnameinfo(sockaddr const* sa, socklen_t salen, char* host, + COROSIO_FAULT_NI_LEN hostlen, char* serv, COROSIO_FAULT_NI_LEN servlen, + int flags) +{ + COROSIO_FAULT_REAL(getnameinfo, int(*)(sockaddr const*, socklen_t, char*, + COROSIO_FAULT_NI_LEN, char*, COROSIO_FAULT_NI_LEN, int)); + if(should_fail(sys::getnameinfo)) + return errno; + return real(sa, salen, host, hostlen, serv, servlen, flags); +} + +extern "C" int fcntl(int fd, int cmd, ...) +{ + COROSIO_FAULT_REAL(fcntl, int(*)(int, int, ...)); + va_list ap; + va_start(ap, cmd); + // Every corosio use passes an int or nothing; a long covers both on + // the SysV and AAPCS64 ABIs. + long arg = va_arg(ap, long); + va_end(ap); + if(should_fail(sys::fcntl)) + return -1; + return real(fd, cmd, arg); +} + +extern "C" int ioctl(int fd, unsigned long req, ...) COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(ioctl, int(*)(int, unsigned long, ...)); + va_list ap; + va_start(ap, req); + void* arg = va_arg(ap, void*); + va_end(ap); + if(should_fail(sys::ioctl)) + return -1; + return real(fd, req, arg); +} + +extern "C" int open(char const* path, int flags, ...) +{ + COROSIO_FAULT_REAL(open, int(*)(char const*, int, ...)); + unsigned mode = 0; + if(flags & (O_CREAT | O_TMPFILE)) + { + va_list ap; + va_start(ap, flags); + mode = va_arg(ap, unsigned); + va_end(ap); + } + if(should_fail(sys::open)) + return -1; + return real(path, flags, mode); +} + +// The byte-moving census entries additionally consult should_shorten: +// a shortened call still reaches the real function, clamped to the +// armed count, so bytes genuinely move instead of being dropped. +extern "C" ssize_t read(int fd, void* b, size_t n) +{ + COROSIO_FAULT_REAL(read, ssize_t(*)(int, void*, size_t)); + if(should_fail(sys::read)) + return -1; + std::size_t c; + if(should_shorten(sys::read, c)) + return c == 0 ? 0 : real(fd, b, c < n ? c : n); + return real(fd, b, n); +} + +extern "C" ssize_t write(int fd, void const* b, size_t n) +{ + COROSIO_FAULT_REAL(write, ssize_t(*)(int, void const*, size_t)); + if(should_fail(sys::write)) + return -1; + std::size_t c; + if(should_shorten(sys::write, c)) + return c == 0 ? 0 : real(fd, b, c < n ? c : n); + return real(fd, b, n); +} + +// Defined everywhere though only the kqueue write policy calls it; a +// shadow nothing references costs one forward on the rare libc caller. +extern "C" ssize_t writev(int fd, iovec const* v, int n) +{ + COROSIO_FAULT_REAL(writev, ssize_t(*)(int, iovec const*, int)); + if(should_fail(sys::writev)) + return -1; + std::size_t c; + if(should_shorten(sys::writev, c)) + { + if(c == 0) + return 0; + iovec t[64]; + return real(fd, t, truncate_iov(v, n, c, t)); + } + return real(fd, v, n); +} + +extern "C" ssize_t recv(int fd, void* b, size_t n, int f) +{ + COROSIO_FAULT_REAL(recv, ssize_t(*)(int, void*, size_t, int)); + if(should_fail(sys::recv)) + return -1; + std::size_t c; + if(should_shorten(sys::recv, c)) + return c == 0 ? 0 : real(fd, b, c < n ? c : n, f); + return real(fd, b, n, f); +} + +extern "C" ssize_t send(int fd, void const* b, size_t n, int f) +{ + COROSIO_FAULT_REAL(send, ssize_t(*)(int, void const*, size_t, int)); + if(should_fail(sys::send)) + return -1; + std::size_t c; + if(should_shorten(sys::send, c)) + return c == 0 ? 0 : real(fd, b, c < n ? c : n, f); + return real(fd, b, n, f); +} + +extern "C" ssize_t readv(int fd, iovec const* v, int n) +{ + COROSIO_FAULT_REAL(readv, ssize_t(*)(int, iovec const*, int)); + if(should_fail(sys::readv)) + return -1; + std::size_t c; + if(should_shorten(sys::readv, c)) + { + if(c == 0) + return 0; + iovec t[64]; + return real(fd, t, truncate_iov(v, n, c, t)); + } + return real(fd, v, n); +} + +extern "C" ssize_t preadv(int fd, iovec const* v, int n, off_t o) +{ + COROSIO_FAULT_REAL(preadv, ssize_t(*)(int, iovec const*, int, off_t)); + if(should_fail(sys::preadv)) + return -1; + std::size_t c; + if(should_shorten(sys::preadv, c)) + { + if(c == 0) + return 0; + iovec t[64]; + return real(fd, t, truncate_iov(v, n, c, t), o); + } + return real(fd, v, n, o); +} + +extern "C" ssize_t pwritev(int fd, iovec const* v, int n, off_t o) +{ + COROSIO_FAULT_REAL(pwritev, ssize_t(*)(int, iovec const*, int, off_t)); + if(should_fail(sys::pwritev)) + return -1; + std::size_t c; + if(should_shorten(sys::pwritev, c)) + { + if(c == 0) + return 0; + iovec t[64]; + return real(fd, t, truncate_iov(v, n, c, t), o); + } + return real(fd, v, n, o); +} + +extern "C" ssize_t recvmsg(int fd, msghdr* m, int f) +{ + COROSIO_FAULT_REAL(recvmsg, ssize_t(*)(int, msghdr*, int)); + if(should_fail(sys::recvmsg)) + return -1; + std::size_t c; + if(should_shorten(sys::recvmsg, c)) + { + if(c == 0) + return 0; + iovec t[64]; + msghdr mh = *m; + mh.msg_iov = t; + mh.msg_iovlen = truncate_iov(m->msg_iov, (int)m->msg_iovlen, c, t); + ssize_t r = real(fd, &mh, f); + m->msg_namelen = mh.msg_namelen; + m->msg_flags = mh.msg_flags; + m->msg_controllen = mh.msg_controllen; + return r; + } + return real(fd, m, f); +} + +extern "C" ssize_t sendmsg(int fd, msghdr const* m, int f) +{ + COROSIO_FAULT_REAL(sendmsg, ssize_t(*)(int, msghdr const*, int)); + if(should_fail(sys::sendmsg)) + return -1; + std::size_t c; + if(should_shorten(sys::sendmsg, c)) + { + if(c == 0) + return 0; + iovec t[64]; + msghdr mh = *m; + mh.msg_iov = t; + mh.msg_iovlen = truncate_iov(m->msg_iov, (int)m->msg_iovlen, c, t); + return real(fd, &mh, f); + } + return real(fd, m, f); +} + +#if defined(__linux__) +// _FORTIFY_SOURCE routes these through the *_chk entry points, which +// would otherwise reach libc's read/recv/poll directly. +extern "C" ssize_t __read_chk(int fd, void* b, size_t n, size_t) +{ + return ::read(fd, b, n); +} + +extern "C" ssize_t __recv_chk(int fd, void* b, size_t n, size_t, int f) +{ + return ::recv(fd, b, n, f); +} + +extern "C" ssize_t __recvfrom_chk(int fd, void* b, size_t n, size_t, int f, + sockaddr* a, socklen_t* l) +{ + COROSIO_FAULT_REAL(recvfrom, ssize_t(*)(int, void*, size_t, int, sockaddr*, socklen_t*)); + if(should_fail(sys::recv)) + return -1; + return real(fd, b, n, f, a, l); +} + +extern "C" int __poll_chk(pollfd* p, nfds_t n, int t, size_t) +{ + return ::poll(p, n, t); +} + +extern "C" ssize_t __pread64_chk(int fd, void* b, size_t n, off64_t o, size_t) +{ + COROSIO_FAULT_REAL(pread64, ssize_t(*)(int, void*, size_t, off64_t)); + if(should_fail(sys::read)) + return -1; + return real(fd, b, n, o); +} + +// __open_2 is called with the mode omitted, but our `open` shadow is +// variadic and reads a mode via va_arg when O_CREAT/O_TMPFILE is set; +// passing an explicit dummy 0 keeps that read well-defined instead of +// pulling an unsupplied argument. glibc's own __open_2 aborts if +// O_CREAT is set without a mode — no corosio call site does that. +extern "C" int __open_2(char const* path, int flags) +{ + return ::open(path, flags, 0); +} + +extern "C" int __gethostname_chk(char* b, size_t n, size_t) noexcept +{ + return ::gethostname(b, n); +} +#endif + +namespace boost::corosio::test::fault { + +#if defined(__APPLE__) +namespace { + +// dyld's index for the loaded corosio dylib, or -1 in a static build. +// Taking the address of a dylib function from the executable can yield +// a stub in this image, so dladdr reports both addresses in the same +// image and cannot tell a shared build from a static one; ask dyld +// directly instead. Index 0 is the executable, and only a leading +// basename counts: a static build whose own path happens to contain +// the library name must not read as shared. +int corosio_image_index() noexcept +{ + static constexpr char prefix[] = "libboost_corosio"; + for(std::uint32_t i = 1, n = ::_dyld_image_count(); i < n; ++i) + { + char const* path = ::_dyld_get_image_name(i); + if(!path) + continue; + char const* slash = std::strrchr(path, '/'); + char const* base = slash ? slash + 1 : path; + if(std::strncmp(base, prefix, sizeof(prefix) - 1) == 0) + return static_cast(i); + } + return -1; +} + +} // namespace +#endif + +bool corosio_is_shared() noexcept +{ +#if defined(__APPLE__) + return corosio_image_index() >= 0; +#else + Dl_info lib{}, exe{}; + // host_name is an ordinary exported corosio function; the hook + // lives in the executable by construction. + ::dladdr(reinterpret_cast(&boost::corosio::host_name), &lib); + ::dladdr(reinterpret_cast(&::socket), &exe); + return lib.dli_fbase != exe.dli_fbase; +#endif +} + +namespace { + +struct census_entry +{ + char const* name; + void const* hook; +}; + +#define COROSIO_FAULT_CENSUS(name) { #name, reinterpret_cast(&::name) } + +} // namespace + +#if defined(__linux__) +// Not declared by any header we include in a non-fortified build (glibc +// only exposes these under __USE_FORTIFY_LEVEL > 0); the definitions +// above are the only declaration these need. +extern "C" ssize_t __read_chk(int, void*, size_t, size_t); +extern "C" ssize_t __recv_chk(int, void*, size_t, size_t, int); +extern "C" ssize_t __recvfrom_chk(int, void*, size_t, size_t, int, sockaddr*, socklen_t*); +extern "C" int __poll_chk(pollfd*, nfds_t, int, size_t); +extern "C" ssize_t __pread64_chk(int, void*, size_t, off64_t, size_t); +extern "C" int __open_2(char const*, int); +extern "C" int __gethostname_chk(char*, size_t, size_t) noexcept; +#endif + +namespace { + +// One entry per OS symbol the library is expected to reference on this +// platform, read off `nm -u` of the built library. It is the ledger the +// shared-build readback checks, so a symbol no backend here calls does +// not belong in it even when the shadow exists: the Darwin build never +// seeks with lseek, and the epoll/timerfd/eventfd family and the glibc +// fortify aliases have no Darwin counterpart at all. +[[maybe_unused]] census_entry const census[] = { + COROSIO_FAULT_CENSUS(socket), COROSIO_FAULT_CENSUS(socketpair), + COROSIO_FAULT_CENSUS(bind), COROSIO_FAULT_CENSUS(listen), + COROSIO_FAULT_CENSUS(accept), + COROSIO_FAULT_CENSUS(connect), COROSIO_FAULT_CENSUS(getsockname), + COROSIO_FAULT_CENSUS(getpeername), COROSIO_FAULT_CENSUS(getsockopt), + COROSIO_FAULT_CENSUS(setsockopt), COROSIO_FAULT_CENSUS(shutdown), + COROSIO_FAULT_CENSUS(close), COROSIO_FAULT_CENSUS(read), + COROSIO_FAULT_CENSUS(write), COROSIO_FAULT_CENSUS(readv), + COROSIO_FAULT_CENSUS(preadv), COROSIO_FAULT_CENSUS(pwritev), + COROSIO_FAULT_CENSUS(recv), COROSIO_FAULT_CENSUS(send), + COROSIO_FAULT_CENSUS(recvmsg), COROSIO_FAULT_CENSUS(sendmsg), + COROSIO_FAULT_CENSUS(poll), COROSIO_FAULT_CENSUS(pipe), + COROSIO_FAULT_CENSUS(fcntl), COROSIO_FAULT_CENSUS(ioctl), + COROSIO_FAULT_CENSUS(open), COROSIO_FAULT_CENSUS(fstat), + COROSIO_FAULT_CENSUS(ftruncate), + COROSIO_FAULT_CENSUS(fsync), COROSIO_FAULT_CENSUS(unlink), + COROSIO_FAULT_CENSUS(sigaction), COROSIO_FAULT_CENSUS(getaddrinfo), + COROSIO_FAULT_CENSUS(freeaddrinfo), + COROSIO_FAULT_CENSUS(getnameinfo), COROSIO_FAULT_CENSUS(gethostname), +#if defined(__linux__) || defined(__FreeBSD__) + COROSIO_FAULT_CENSUS(fdatasync), COROSIO_FAULT_CENSUS(posix_fadvise), +#endif + // Darwin spells select with an asm label and gets its two aliases + // below instead of the plain name. +#if !defined(__APPLE__) + COROSIO_FAULT_CENSUS(select), +#endif +#if defined(__linux__) + COROSIO_FAULT_CENSUS(accept4), COROSIO_FAULT_CENSUS(lseek), + COROSIO_FAULT_CENSUS(epoll_create1), COROSIO_FAULT_CENSUS(epoll_ctl), + COROSIO_FAULT_CENSUS(epoll_wait), COROSIO_FAULT_CENSUS(eventfd), + COROSIO_FAULT_CENSUS(timerfd_create), COROSIO_FAULT_CENSUS(timerfd_settime), + COROSIO_FAULT_CENSUS(__read_chk), COROSIO_FAULT_CENSUS(__recv_chk), + COROSIO_FAULT_CENSUS(__recvfrom_chk), COROSIO_FAULT_CENSUS(__poll_chk), + COROSIO_FAULT_CENSUS(__pread64_chk), + COROSIO_FAULT_CENSUS(__open_2), COROSIO_FAULT_CENSUS(__gethostname_chk), +#endif +#if defined(__APPLE__) || defined(__FreeBSD__) + COROSIO_FAULT_CENSUS(writev), COROSIO_FAULT_CENSUS(kqueue), + COROSIO_FAULT_CENSUS(kevent), +#endif +#if defined(__APPLE__) + { "select", reinterpret_cast(&::corosio_fault_select) }, + { "select$DARWIN_EXTSN", + reinterpret_cast(&::corosio_fault_select_extsn) }, +#endif +#if BOOST_COROSIO_HAVE_LIBURING + COROSIO_FAULT_CENSUS(io_uring_queue_init_params), + COROSIO_FAULT_CENSUS(io_uring_queue_exit), + COROSIO_FAULT_CENSUS(io_uring_submit), + COROSIO_FAULT_CENSUS(io_uring_submit_and_wait_timeout), + COROSIO_FAULT_CENSUS(io_uring_submit_and_get_events), + COROSIO_FAULT_CENSUS(io_uring_wait_cqe_timeout), +#endif +}; + +// Alias entries name a second spelling of a symbol the library may or +// may not have been built to call: the glibc fortify wrappers and the +// Darwin `$` suffixes. +[[maybe_unused]] bool is_alias_entry(char const* name) noexcept +{ + return std::strncmp(name, "__", 2) == 0 || std::strchr(name, '$'); +} + +#if defined(__APPLE__) + +// One census symbol whose import slot in the dylib is to be rewritten. +// Mach-O's two-level namespace records libSystem as the source of the +// dylib's imports at link time, so a bound slot holds exactly +// libSystem's entry point for the symbol and can be recognised by its +// value alone. That reads the same in a classic lazy-pointer section +// and in the `__got` of a chained-fixups image, and needs neither the +// indirect symbol table nor the chained-fixup imports table, both of +// which vary with the linker that produced the dylib. +struct rebind_target +{ + char const* name; + void const* hook; + void const* real; + unsigned rebound; + unsigned unbound; +}; + +// Visit every section of `hdr` that can hold a bound import pointer: +// the classic lazy and non-lazy pointer sections, plus the `__got` +// family a chained-fixups image uses in their place. +template +void for_each_import_section(mach_header_64 const* hdr, std::intptr_t slide, + F&& f) noexcept +{ + auto const* cmd = reinterpret_cast(hdr + 1); + for(std::uint32_t i = 0; i < hdr->ncmds; ++i) + { + if(cmd->cmd == LC_SEGMENT_64) + { + auto const* seg = reinterpret_cast(cmd); + auto const* sec = reinterpret_cast(seg + 1); + for(std::uint32_t j = 0; j < seg->nsects; ++j, ++sec) + { + std::uint32_t const type = sec->flags & SECTION_TYPE; + if(type != S_NON_LAZY_SYMBOL_POINTERS && + type != S_LAZY_SYMBOL_POINTERS && + std::strncmp(sec->sectname, "__got", 16) != 0 && + std::strncmp(sec->sectname, "__auth_got", 16) != 0) + continue; + std::size_t const count = + static_cast(sec->size) / sizeof(void*); + if(count == 0) + continue; + f(seg, sec, reinterpret_cast( + static_cast(sec->addr) + slide), count); + } + } + cmd = reinterpret_cast( + reinterpret_cast(cmd) + cmd->cmdsize); + } +} + +// Ask the kernel for `prot` over the pages covering the byte range. +// VM_PROT_COPY asks for a private copy of a file-backed mapping, which +// is what a plain write request is refused on. +bool protect_pages(void* addr, std::size_t bytes, vm_prot_t prot) noexcept +{ + auto const page = static_cast(::getpagesize()); + auto const start = reinterpret_cast(addr); + vm_address_t const begin = start & ~(page - 1); + vm_size_t const len = ((start + bytes + page - 1) & ~(page - 1)) - begin; + if(::vm_protect(mach_task_self(), begin, len, FALSE, prot) + == KERN_SUCCESS) + return true; + return ::vm_protect(mach_task_self(), begin, len, FALSE, + prot | VM_PROT_COPY) == KERN_SUCCESS; +} + +// True for a segment dyld maps read-only again once it has applied its +// fixups, which is the one that has to be reprotected after the write. +bool is_const_segment(segment_command_64 const* seg) noexcept +{ + char name[17] = {}; + std::memcpy(name, seg->segname, 16); + return std::strstr(name, "_CONST") != nullptr; +} + +// Point every matching import slot at the shadow. Reports through +// `ok`, and leaves the counting to the verification pass so that what +// is checked is the memory as it stands afterwards, not what this +// pass believes it wrote. +void rebind_imports(mach_header_64 const* hdr, std::intptr_t slide, + rebind_target const* targets, std::size_t n, bool& ok) noexcept +{ + for_each_import_section(hdr, slide, + [&](segment_command_64 const* seg, section_64 const* sec, + void** slots, std::size_t count) + { + std::size_t hits = 0; + for(std::size_t i = 0; i < count; ++i) + { + for(std::size_t k = 0; k < n; ++k) + { + if(slots[i] == targets[k].real) + ++hits; + } + } + if(hits == 0) + return; + if(!protect_pages(slots, count * sizeof(void*), + VM_PROT_READ | VM_PROT_WRITE)) + { + std::fprintf(stderr, + "fault harness: %.16s,%.16s refused to become writable\n", + seg->segname, sec->sectname); + ok = false; + return; + } + for(std::size_t i = 0; i < count; ++i) + { + for(std::size_t k = 0; k < n; ++k) + { + if(slots[i] != targets[k].real) + continue; + slots[i] = const_cast(targets[k].hook); + break; + } + } + if(is_const_segment(seg)) + std::ignore = protect_pages(slots, count * sizeof(void*), + VM_PROT_READ); + }); +} + +// Count, per symbol, the slots that now hold the shadow and the slots +// that still hold libSystem's entry point. `scanned` carries the size +// of the search, which is what separates a rewrite that missed a +// symbol from a Mach-O layout this walk does not recognise at all. +void tally_imports(mach_header_64 const* hdr, std::intptr_t slide, + rebind_target* targets, std::size_t n, std::size_t& sections, + std::size_t& scanned) noexcept +{ + for_each_import_section(hdr, slide, + [&](segment_command_64 const*, section_64 const*, void** slots, + std::size_t count) + { + ++sections; + scanned += count; + for(std::size_t i = 0; i < count; ++i) + { + for(std::size_t k = 0; k < n; ++k) + { + if(slots[i] == targets[k].hook) + ++targets[k].rebound; + else if(slots[i] == targets[k].real) + ++targets[k].unbound; + } + } + }); +} + +// Rewrite the loaded corosio dylib's import slots so that library code +// reaches the shadows. dyld applies `__DATA,__interpose` only from +// dylibs, never from the main executable, and the two-level namespace +// leaves an executable-defined shadow out of the search entirely, so +// this is the only interposition available to a test binary. +void interpose_corosio_dylib() noexcept +{ +#if defined(__has_feature) +#if __has_feature(ptrauth_calls) + // An arm64e slot holds a signed pointer; a plain store would + // install a value the caller's authenticated branch rejects. + die("fault harness: pointer-authenticated import slots cannot be " + "rebound by a plain store"); +#endif +#endif + int const image = corosio_image_index(); + if(image < 0) + die("fault harness: the corosio dylib left dyld's image list"); + auto const* hdr = reinterpret_cast( + ::_dyld_get_image_header(static_cast(image))); + if(!hdr || hdr->magic != MH_MAGIC_64) + die("fault harness: the corosio dylib is not a 64-bit Mach-O image"); + auto const slide = + ::_dyld_get_image_vmaddr_slide(static_cast(image)); + + rebind_target targets[sizeof(census) / sizeof(census[0])] = {}; + std::size_t n = 0; + for(auto const& e : census) + { + // An alias is a second spelling the library is not known to + // bind; libSystem may even give both spellings one entry + // point, which a value match cannot tell apart. + if(is_alias_entry(e.name)) + continue; + void* real = ::dlsym(RTLD_NEXT, e.name); + if(!real) + { + char msg[160]; + std::snprintf(msg, sizeof(msg), + "fault harness: %s has no implementation behind the shadow", + e.name); + die(msg); + } + for(std::size_t k = 0; k < n; ++k) + { + if(targets[k].real != real) + continue; + char msg[192]; + std::snprintf(msg, sizeof(msg), + "fault harness: %s and %s share one libSystem entry point", + targets[k].name, e.name); + die(msg); + } + targets[n].name = e.name; + targets[n].hook = e.hook; + targets[n].real = real; + ++n; + } + + bool ok = true; + rebind_imports(hdr, slide, targets, n, ok); + std::size_t sections = 0, scanned = 0; + tally_imports(hdr, slide, targets, n, sections, scanned); + for(std::size_t k = 0; k < n; ++k) + { + auto const& t = targets[k]; + if(t.unbound != 0) + { + std::fprintf(stderr, "fault harness: %s still reaches libSystem " + "through %u of the dylib's import slots\n", t.name, t.unbound); + ok = false; + } + // A census name with no slot at all is as much a defect as one + // that refused to move: every arm on it would sit dead. + if(t.rebound == 0) + { + std::fprintf(stderr, + "fault harness: %s is not among the dylib's imports\n", + t.name); + ok = false; + } + } + if(!ok) + { + std::fprintf(stderr, "fault harness: %zu import sections, %zu slots " + "scanned in %s\n", sections, scanned, + ::_dyld_get_image_name(static_cast(image))); + die("fault harness: the corosio dylib's imports were not rebound"); + } +} +#endif + +// A shared build only reaches the shadows through the dynamic loader, +// and a loader that resolved the library's calls elsewhere would leave +// every fault silently dead. Prove the binding instead of assuming it: +// on ELF the library binds through the executable's dynamic symbol +// table, which -Bsymbolic or -fno-plt would bypass; on Mach-O the +// binding has to be installed here first. +int const readback = [] +{ + if(!corosio_is_shared()) + return 0; +#if defined(__APPLE__) + interpose_corosio_dylib(); +#else + bool ok = true; + for(auto const& e : census) + { + void* bound = ::dlsym(RTLD_DEFAULT, e.name); + // The linker exports an executable symbol into .dynsym only + // when a linked .so references it; a mismatch here just means + // the library wasn't built to call this alias. A genuinely + // broken interposition (-Bsymbolic, -fno-plt) fails on the + // plain census names first, not here. + if(bound != e.hook && is_alias_entry(e.name)) + continue; + if(bound != e.hook) + { + std::fprintf(stderr, "fault harness: %s is bound to %p, hook is %p\n", + e.name, bound, e.hook); + ok = false; + } + } + if(!ok) + die("fault harness: shadows are not interposing libboost_corosio.so"); +#endif + return 0; +}(); + +} // namespace +} // boost::corosio::test::fault diff --git a/test/unit/fault/fault_slot.hpp b/test/unit/fault/fault_slot.hpp new file mode 100644 index 000000000..dcafc966b --- /dev/null +++ b/test/unit/fault/fault_slot.hpp @@ -0,0 +1,152 @@ +// +// 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_TEST_FAULT_SLOT_HPP +#define BOOST_COROSIO_TEST_FAULT_SLOT_HPP + +#include "fault.hpp" + +#include +#include + +namespace boost::corosio::test::fault { + +// Per-thread arm state. Plain aggregate so the hooks touch nothing +// that could allocate or lock. +struct slot +{ + sys which = sys::count_; + int err = 0; + std::size_t count = 0; + unsigned nth = 1; + unsigned seen = 0; + bool short_mode = false; + bool fired = false; + bool armed = false; + // Tracks the owning fault_scope's lifetime, independent of `armed`: + // should_fail/should_shorten clear `armed` the moment the fault + // fires, but the scope object is still alive and keeps its arm + // reserved until destroyed. + bool owned = false; +}; + +// Four independent arms per thread. A deferred-path test parks an +// operation with one fault and fails its reactor retry with another, +// and every armed arm counts calls on its own, so two arms may target +// different occurrences of the same symbol. +struct arm_set +{ + static constexpr int max_arms = 4; + slot arms[max_arms]; +}; + +extern thread_local arm_set tls_arms; + +// Return the first arm still armed for `which` on this thread, or null. +slot* armed_arm(sys which) noexcept; + +// Process-wide fallback, consulted only when the calling thread has no +// arm watching the symbol being called; an arm watching some other +// symbol does not shadow it. Work the library hands to its thread pool +// (file I/O, name resolution) cannot see the test thread's arms, so a +// scope armed with `any_thread` publishes itself here instead. +extern std::atomic global_slot; + +// Publish `err` where a caller of the failing entry point will read it: +// errno on POSIX, the last-error slots on Windows. The one part of the +// arm model that has to know which harness it was linked into, which +// is why fault_arm.cpp can be shared verbatim. +void publish_error(int err) noexcept; + +// Return true when the current call must fail; the error is already +// published. +bool should_fail(sys which) noexcept; + +// Return true when the current call must be shortened to `count`. +bool should_shorten(sys which, std::size_t& count) noexcept; + +// Print `msg` and abort. Defined in fault_arm.cpp so every hook +// translation unit enforces contract violations the same way. +[[noreturn]] void die(char const* msg) noexcept; + +#if !defined(_WIN32) +// Resolve the real entry point behind a shadow. A symbol that resolves +// to null would otherwise only surface as a crash inside the shadow, +// with nothing to say which one; name it instead. The Windows hooks +// keep the real entry point from the import thunk they overwrote and +// never look a symbol up by name. +void* real_symbol(char const* name) noexcept; +#endif + +// Second slot for CQE rewriting; independent of `slot` so a test can +// pair an SQ-full fault with a completion rewrite. +struct cqe_slot +{ + int fd = -1; + int opcode = -1; + int res = 0; + unsigned long long user_data = 0; + bool have_user_data = false; + bool fired = false; + bool armed = false; + // Tracks the owning scope's lifetime, independent of `armed`: the + // rewrite clears `armed` the moment it fires, so nesting has to be + // refused on this instead or a second scope would quietly take + // over the slot a live one still reads `fired()` from. + bool owned = false; +}; + +extern thread_local cqe_slot tls_cqe; + +#if defined(_WIN32) +// The IOCP twin of cqe_slot: a completion carries no fd or opcode to +// match on, only its ordinal among the dequeues that produced an +// OVERLAPPED, so the two cannot share one aggregate. +struct completion_slot +{ + unsigned long err = 0; + unsigned nth = 1; + unsigned seen = 0; + bool fired = false; + bool armed = false; + // See cqe_slot::owned; the rule is the same. + bool owned = false; +}; + +extern thread_local completion_slot tls_completion; + +// Return true when the current dequeue must be turned into a failure, +// reporting the armed error through `err`. +bool completion_should_fail(unsigned long& err) noexcept; +#endif + +// Claim the one completion-side slot of its kind for a scope, or die +// naming `who`. Shared by cqe_fault_scope and completion_fault_scope, +// whose slots hold different things but reserve them by the same rule. +template +void claim_completion_slot(Slot& s, char const* who) noexcept +{ + if(s.owned) + die(who); + s = Slot{}; + s.armed = true; + s.owned = true; +} + +// Release the slot claimed by claim_completion_slot. +template +void release_completion_slot(Slot& s) noexcept +{ + s.armed = false; + s.owned = false; +} + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/fault_test_utils.hpp b/test/unit/fault/fault_test_utils.hpp new file mode 100644 index 000000000..145385bcc --- /dev/null +++ b/test/unit/fault/fault_test_utils.hpp @@ -0,0 +1,266 @@ +// +// 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_TEST_FAULT_TEST_UTILS_HPP +#define BOOST_COROSIO_TEST_FAULT_TEST_UTILS_HPP + +#include "fault.hpp" +#include "test_suite.hpp" + +#if defined(__FreeBSD__) +// real_symbol: the descriptor scan below must not spend a live `fcntl` +// arm on its own probing. +#include "fault_slot.hpp" +#endif + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#include +#include +#include +#endif + +namespace boost::corosio::test::fault { + +#if defined(_WIN32) + +// The handle count is Windows' answer to the descriptor count: a +// socket, a file and a completion port all show up in it, which is +// what the leak assertions need. It moves on its own as the CRT and +// the thread pool come and go, so only differences taken across a +// short failing call mean anything. +inline int open_fds() +{ + DWORD n = 0; + if(!::GetProcessHandleCount(::GetCurrentProcess(), &n)) + return -1; + return static_cast(n); +} + +// Windows has no fork, so there is no isolated process to run `body` +// in: it runs here. Faults that install process-wide state — the +// signal handlers — therefore have to live in a suite of their own, +// where nothing else has installed that state first. +template +void in_child(F&& body) +{ + BOOST_TEST(body()); +} + +// Assert that a repeatedly failing call releases what it creates. +// +// GetProcessHandleCount is process-wide and moves on its own — a +// Winsock provider loading on the first socket of a family, a runtime +// thread coming or going — and it has been observed drifting in both +// directions across a single pair of samples. An exact before/after +// comparison is therefore not a leak signal at all. Instead `fn` runs +// once to absorb the first-use cost, and the count is taken across +// enough repetitions that a per-call leak, which grows the count once +// per call, separates from that ambient noise. +template +void expect_no_handle_leak(F&& fn) +{ + constexpr int reps = 8; + fn(); + int const before = open_fds(); + // open_fds() answers -1 when the count cannot be read, which would + // otherwise satisfy the comparison below on its own. + BOOST_TEST(before >= 0); + for(int i = 0; i < reps; ++i) + fn(); + int const after = open_fds(); + // A -1 here would satisfy the growth comparison on its own. + BOOST_TEST(after >= 0); + BOOST_TEST(after - before < reps); +} + +// The Win32 and Winsock codes the library hands back unchanged compare +// equal only to themselves: which of them a toolchain's system_category +// also matches to a std::errc condition differs between MSVC and MinGW, +// so a test spells such an expectation as the raw code. +inline std::error_code win_err(DWORD e) +{ + return std::error_code(static_cast(e), std::system_category()); +} + +inline std::string temp_path(char const* tag) +{ + char dir[MAX_PATH + 1] = {}; + DWORD const n = ::GetTempPathA(sizeof(dir), dir); + // An unusable TEMP is not worth a fallback that might be + // unwritable; the test that opens the path reports it. + std::string base(dir, n); + return base + "corosio_fault_" + tag + "_" + + std::to_string(::GetCurrentProcessId()); +} + +#else + +// Count open descriptors so a test can prove the failure path released +// what it created. +inline int open_fds() +{ +#if defined(__FreeBSD__) + // FreeBSD mounts neither /proc nor fdescfs by default, and /dev/fd + // without fdescfs lists only 0-2, so the table is probed one entry + // at a time. The probe goes through the real fcntl rather than the + // shadow: a test that samples the count inside a live `fcntl` arm + // would otherwise spend that arm on the scan. + static auto const real_fcntl = + reinterpret_cast(real_symbol("fcntl")); + long const lim = ::sysconf(_SC_OPEN_MAX); + // 65536 bounds the scan on a host with an enormous rlimit; nothing + // here opens anywhere near that many descriptors. + long const stop = (lim < 0 || lim > 65536) ? 65536 : lim; + int n = 0; + for(long fd = 0; fd < stop; ++fd) + { + if(real_fcntl(static_cast(fd), F_GETFD) != -1) + ++n; + } + return n; +#else +#if defined(__APPLE__) + // Darwin has no /proc; /dev/fd is the same per-process listing. + DIR* d = ::opendir("/dev/fd"); +#else + DIR* d = ::opendir("/proc/self/fd"); +#endif + // -1 rather than 0: an unreadable /proc/self/fd or /dev/fd must + // break the leak assertions, not satisfy them. + if(!d) + return -1; + int n = 0; + while(::readdir(d)) + ++n; + ::closedir(d); + return n; +#endif +} + +// Run `body` in a forked child and assert it returned true. Process-wide +// state that is created once — the signal self-pipe and its sigaction +// handlers — can only be faulted in a fresh process, and installing it +// in this one would silently disarm every other test that faults it. +template +void in_child(F&& body) +{ + pid_t pid = ::fork(); + BOOST_TEST(pid >= 0); + if(pid < 0) + return; + if(pid == 0) + std::_Exit(body() ? 0 : 1); + int status = 0; + ::waitpid(pid, &status, 0); + BOOST_TEST(WIFEXITED(status) && WEXITSTATUS(status) == 0); +} + +inline std::string temp_path(char const* tag) +{ + // A build that confines the process to its own scratch directory + // sets TMPDIR; /tmp may not even be writable there. + char const* dir = std::getenv("TMPDIR"); + std::string base = (dir && *dir) ? dir : "/tmp"; + if(base.back() != '/') + base += '/'; + return base + "corosio_fault_" + tag + "_" + std::to_string(::getpid()); +} + +#endif + +// Return true if the process runs under Valgrind. Valgrind always maps +// its preload library, so scanning the map is enough and does not need +// valgrind.h on the include path. +inline bool running_under_valgrind() noexcept +{ +#if defined(_WIN32) + return false; +#else + std::FILE* f = std::fopen("/proc/self/maps", "re"); + if(!f) + return false; + bool found = false; + char line[4096]; + while(std::fgets(line, sizeof(line), f)) + { + if(std::strstr(line, "vgpreload")) + { + found = true; + break; + } + } + std::fclose(f); + return found; +#endif +} + +// Loud-skip a whole fault suite under Valgrind. Valgrind redirects the +// same libc entry points the shadows interpose, so the arms cannot fire +// and would report spurious failures. Interposition-based fault +// injection is not meaningful there, exactly as it is not under the +// sanitizers the Jamfile already excludes. Report the reason so the leg +// passes with a visible cause rather than a silent one. +inline bool skip_under_valgrind() noexcept +{ + if(!running_under_valgrind()) + return false; + std::fprintf(stderr, + "fault harness: running under Valgrind, which redirects the libc " + "entry points the shadows interpose; skipping this fault suite\n"); + return true; +} + +// Report a hook that has no import to patch in this executable. A +// silent `return` from a test would read as a pass; name the symbol so +// the log says which coverage the run did not have. +inline void skip_dead_hook(char const* name) +{ + std::fprintf(stderr, + "fault harness: %s not imported by this executable; skipping the " + "test that arms it\n", name); +} + +// BOOST_TEST_THROWS accepts any std::system_error, which would pass +// even if the library reported an error the fault never injected. +// `Expected` is a std::errc where the library normalizes the code and a +// std::error_code where it hands back the raw platform value. +template +void expect_system_error(F&& fn, Expected expected) +{ + std::error_code caught; + try + { + fn(); + } + catch(std::system_error const& e) + { + caught = e.code(); + } + BOOST_TEST(caught == expected); +} + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/fault_uring.cpp b/test/unit/fault/fault_uring.cpp new file mode 100644 index 000000000..7fcab9b70 --- /dev/null +++ b/test/unit/fault/fault_uring.cpp @@ -0,0 +1,176 @@ +// +// 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 +// + +#include "fault_slot.hpp" + +#include +#include +#include +#include + +// liburing 2.6 and later declare their API noexcept in C++; older +// headers leave it unspecified, and a shadow that adds a specification +// the header does not have is ill-formed. +#if !defined(LIBURING_NOEXCEPT) +#define LIBURING_NOEXCEPT +#endif + +using namespace boost::corosio::test::fault; + +namespace { + +// An archive-linked liburing leaves no body behind the shadow: the +// executable's own definition already satisfies the reference, so the +// archive member is never pulled in and RTLD_NEXT has nothing left to +// find. The distro's shared object supplies one. Every ring call in +// the process goes through these shadows, so the ring is still driven +// by a single implementation. +void* uring_real_symbol(char const* name) noexcept +{ + if(void* p = ::dlsym(RTLD_NEXT, name)) + return p; + static void* const lib = ::dlopen("liburing.so.2", RTLD_NOW | RTLD_LOCAL); + if(lib) + { + if(void* p = ::dlsym(lib, name)) + return p; + } + return real_symbol(name); +} + +#define COROSIO_FAULT_REAL(name, sig) \ + static auto const real = reinterpret_cast(uring_real_symbol(#name)) + +// Fail with a negative errno, liburing style. +bool uring_fail(sys which, int& rc) noexcept +{ + if(!should_fail(which)) + return false; + rc = -errno; + return true; +} + +slot* sqe_full_armed() noexcept +{ + return armed_arm(sys::uring_sqe_full); +} + +// Record the user_data of the first pending SQE matching the armed +// fd/opcode. The SQ array is user memory, so this is a plain read. +void scan_pending_sqes(io_uring* ring) noexcept +{ + auto& c = tls_cqe; + if(!c.armed || c.have_user_data) + return; + for(unsigned i = ring->sq.sqe_head; i != ring->sq.sqe_tail; ++i) + { + auto const& sqe = ring->sq.sqes[i & ring->sq.ring_mask]; + if(int(sqe.opcode) == c.opcode && sqe.fd == c.fd) + { + c.user_data = sqe.user_data; + c.have_user_data = true; + return; + } + } +} + +// Overwrite `res` on the visible CQE carrying the recorded user_data. +void rewrite_visible_cqes(io_uring* ring) noexcept +{ + auto& c = tls_cqe; + if(!c.armed || !c.have_user_data) + return; + unsigned head; + io_uring_cqe* cqe; + io_uring_for_each_cqe(ring, head, cqe) + { + if(cqe->user_data == c.user_data) + { + cqe->res = c.res; + c.fired = true; + c.armed = false; + return; + } + } +} + +} // namespace + +extern "C" int io_uring_queue_init_params(unsigned entries, io_uring* ring, + io_uring_params* p) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_queue_init_params, int(*)(unsigned, io_uring*, io_uring_params*)); + int rc; + if(uring_fail(sys::io_uring_queue_init_params, rc)) + return rc; + if(sqe_full_armed()) + entries = 1; + return real(entries, ring, p); +} + +extern "C" void io_uring_queue_exit(io_uring* ring) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_queue_exit, void(*)(io_uring*)); + // Cannot fail; counted so a test can assert teardown reached it. + std::ignore = should_fail(sys::io_uring_queue_exit); + real(ring); +} + +extern "C" int io_uring_submit(io_uring* ring) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_submit, int(*)(io_uring*)); + int rc; + if(uring_fail(sys::io_uring_submit, rc)) + return rc; + if(auto* s = sqe_full_armed()) + { + s->fired = true; + return 0; + } + scan_pending_sqes(ring); + return real(ring); +} + +extern "C" int io_uring_submit_and_wait_timeout(io_uring* ring, io_uring_cqe** cqe, + unsigned wait_nr, __kernel_timespec* ts, sigset_t* sigmask) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_submit_and_wait_timeout, + int(*)(io_uring*, io_uring_cqe**, unsigned, __kernel_timespec*, sigset_t*)); + int rc; + if(uring_fail(sys::io_uring_submit_and_wait_timeout, rc)) + return rc; + scan_pending_sqes(ring); + rc = real(ring, cqe, wait_nr, ts, sigmask); + rewrite_visible_cqes(ring); + return rc; +} + +extern "C" int io_uring_submit_and_get_events(io_uring* ring) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_submit_and_get_events, int(*)(io_uring*)); + int rc; + if(uring_fail(sys::io_uring_submit_and_get_events, rc)) + return rc; + scan_pending_sqes(ring); + rc = real(ring); + rewrite_visible_cqes(ring); + return rc; +} + +extern "C" int io_uring_wait_cqe_timeout(io_uring* ring, io_uring_cqe** cqe, + __kernel_timespec* ts) LIBURING_NOEXCEPT +{ + COROSIO_FAULT_REAL(io_uring_wait_cqe_timeout, int(*)(io_uring*, io_uring_cqe**, __kernel_timespec*)); + int rc; + if(uring_fail(sys::io_uring_wait_cqe_timeout, rc)) + return rc; + rc = real(ring, cqe, ts); + rewrite_visible_cqes(ring); + return rc; +} diff --git a/test/unit/fault/fault_win.cpp b/test/unit/fault/fault_win.cpp new file mode 100644 index 000000000..a66a49e72 --- /dev/null +++ b/test/unit/fault/fault_win.cpp @@ -0,0 +1,867 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_slot.hpp" + +#if defined(_WIN32) + +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace boost::corosio::test::fault { + +thread_local completion_slot tls_completion; + +// The arm machinery lives in fault_arm.cpp; this is the one piece of +// it that has to know what platform it is on. Winsock and the kernel +// share the per-thread error slot, but the two setters are separate +// entry points and a hook cannot know which one its caller will read; +// publishing through both costs nothing on a path already failing. +void publish_error(int err) noexcept +{ + ::SetLastError(static_cast(err)); + ::WSASetLastError(err); +} + +bool completion_should_fail(unsigned long& err) noexcept +{ + auto& c = tls_completion; + if(!c.armed) + return false; + if(++c.seen != c.nth) + return false; + c.armed = false; + c.fired = true; + err = c.err; + return true; +} + +completion_fault_scope::completion_fault_scope(unsigned long err, unsigned nth) +{ + claim_completion_slot(tls_completion, + "completion_fault_scope: a completion fault is already armed " + "on this thread"); + tls_completion.err = err; + tls_completion.nth = nth; +} + +completion_fault_scope::~completion_fault_scope() +{ + release_completion_slot(tls_completion); +} + +bool completion_fault_scope::fired() const noexcept +{ + return tls_completion.fired; +} + +namespace { + +// A generic function pointer. Every cast between an entry point's real +// type and this one goes through void(*)(), the one function type that +// -Wcast-function-type accepts in both directions. +using proc_t = void (*)(); + +// The entry points reached through an import table. Every hook is +// generated from this list: the id, the forwarding pointer slot, the +// hook itself and the table row all keep the list's order. +// +// `failval` is evaluated only after should_fail has published the +// armed error, which is what lets the entry points that report through +// their return value hand back ::GetLastError(). +#define COROSIO_FAULT_WIN_SIMPLE(X) \ + X(socket, SOCKET, INVALID_SOCKET, WSAAPI, \ + (int af, int type, int protocol), (af, type, protocol)) \ + X(WSASocketW, SOCKET, INVALID_SOCKET, WSAAPI, \ + (int af, int type, int protocol, LPWSAPROTOCOL_INFOW pi, GROUP g, \ + DWORD flags), \ + (af, type, protocol, pi, g, flags)) \ + X(bind, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr const* a, int len), (s, a, len)) \ + X(listen, int, SOCKET_ERROR, WSAAPI, (SOCKET s, int backlog), \ + (s, backlog)) \ + X(accept, SOCKET, INVALID_SOCKET, WSAAPI, \ + (SOCKET s, sockaddr* a, int* len), (s, a, len)) \ + X(connect, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr const* a, int len), (s, a, len)) \ + X(shutdown, int, SOCKET_ERROR, WSAAPI, (SOCKET s, int how), (s, how)) \ + X(closesocket, int, SOCKET_ERROR, WSAAPI, (SOCKET s), (s)) \ + X(ioctlsocket, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, long cmd, u_long* argp), (s, cmd, argp)) \ + X(getsockname, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr* a, int* len), (s, a, len)) \ + X(getpeername, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr* a, int* len), (s, a, len)) \ + X(getsockopt, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, int lvl, int name, char* val, int* len), \ + (s, lvl, name, val, len)) \ + X(setsockopt, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, int lvl, int name, char const* val, int len), \ + (s, lvl, name, val, len)) \ + X(WSAConnect, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, sockaddr const* a, int len, LPWSABUF cd, LPWSABUF ud, \ + LPQOS sq, LPQOS gq), \ + (s, a, len, cd, ud, sq, gq)) \ + X(WSARecvFrom, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, LPWSABUF bufs, DWORD n, LPDWORD got, LPDWORD flags, \ + sockaddr* from, LPINT fromlen, LPWSAOVERLAPPED ov, \ + LPWSAOVERLAPPED_COMPLETION_ROUTINE cr), \ + (s, bufs, n, got, flags, from, fromlen, ov, cr)) \ + X(WSASendTo, int, SOCKET_ERROR, WSAAPI, \ + (SOCKET s, LPWSABUF bufs, DWORD n, LPDWORD sent, DWORD flags, \ + sockaddr const* to, int tolen, LPWSAOVERLAPPED ov, \ + LPWSAOVERLAPPED_COMPLETION_ROUTINE cr), \ + (s, bufs, n, sent, flags, to, tolen, ov, cr)) \ + X(WSAPoll, int, SOCKET_ERROR, WSAAPI, \ + (LPWSAPOLLFD fds, ULONG n, INT timeout), (fds, n, timeout)) \ + X(WSAStartup, int, static_cast(::GetLastError()), WSAAPI, \ + (WORD ver, LPWSADATA data), (ver, data)) \ + X(WSACleanup, int, SOCKET_ERROR, WSAAPI, (), ()) \ + X(GetAddrInfoExW, INT, static_cast(::GetLastError()), WSAAPI, \ + (PCWSTR name, PCWSTR service, DWORD ns, LPGUID nsid, \ + ADDRINFOEXW const* hints, PADDRINFOEXW* res, timeval* timeout, \ + LPOVERLAPPED ov, LPLOOKUPSERVICE_COMPLETION_ROUTINE cr, \ + LPHANDLE handle), \ + (name, service, ns, nsid, hints, res, timeout, ov, cr, handle)) \ + X(GetAddrInfoExCancel, INT, static_cast(::GetLastError()), WSAAPI, \ + (LPHANDLE handle), (handle)) \ + X(GetNameInfoW, INT, static_cast(::GetLastError()), WSAAPI, \ + (sockaddr const* sa, int salen, wchar_t* node, DWORD nodelen, \ + wchar_t* service, DWORD servicelen, INT flags), \ + (sa, salen, node, nodelen, service, servicelen, flags)) \ + X(CreateIoCompletionPort, HANDLE, nullptr, WINAPI, \ + (HANDLE file, HANDLE port, ULONG_PTR key, DWORD threads), \ + (file, port, key, threads)) \ + X(PostQueuedCompletionStatus, BOOL, FALSE, WINAPI, \ + (HANDLE port, DWORD bytes, ULONG_PTR key, LPOVERLAPPED ov), \ + (port, bytes, key, ov)) \ + X(CancelIoEx, BOOL, FALSE, WINAPI, (HANDLE h, LPOVERLAPPED ov), (h, ov)) \ + X(CloseHandle, BOOL, FALSE, WINAPI, (HANDLE h), (h)) \ + X(CreateFileW, HANDLE, INVALID_HANDLE_VALUE, WINAPI, \ + (LPCWSTR name, DWORD access, DWORD share, \ + LPSECURITY_ATTRIBUTES sa, DWORD disp, DWORD flags, \ + HANDLE tmpl), \ + (name, access, share, sa, disp, flags, tmpl)) \ + X(SetFilePointerEx, BOOL, FALSE, WINAPI, \ + (HANDLE h, LARGE_INTEGER dist, PLARGE_INTEGER out, DWORD method), \ + (h, dist, out, method)) \ + X(GetFileSizeEx, BOOL, FALSE, WINAPI, \ + (HANDLE h, PLARGE_INTEGER size), (h, size)) \ + X(SetEndOfFile, BOOL, FALSE, WINAPI, (HANDLE h), (h)) \ + X(FlushFileBuffers, BOOL, FALSE, WINAPI, (HANDLE h), (h)) \ + X(DeleteFileA, BOOL, FALSE, WINAPI, (LPCSTR name), (name)) \ + X(CreateWaitableTimerW, HANDLE, nullptr, WINAPI, \ + (LPSECURITY_ATTRIBUTES sa, BOOL manual, LPCWSTR name), \ + (sa, manual, name)) \ + X(SetWaitableTimer, BOOL, FALSE, WINAPI, \ + (HANDLE h, LARGE_INTEGER const* due, LONG period, \ + PTIMERAPCROUTINE apc, LPVOID arg, BOOL resume), \ + (h, due, period, apc, arg, resume)) \ + X(WaitForSingleObject, DWORD, WAIT_FAILED, WINAPI, \ + (HANDLE h, DWORD ms), (h, ms)) \ + X(GetComputerNameExW, BOOL, FALSE, WINAPI, \ + (COMPUTER_NAME_FORMAT kind, LPWSTR buf, LPDWORD size), \ + (kind, buf, size)) \ + X(GetModuleHandleA, HMODULE, nullptr, WINAPI, (LPCSTR name), (name)) \ + X(GetModuleHandleW, HMODULE, nullptr, WINAPI, (LPCWSTR name), (name)) \ + X(MultiByteToWideChar, int, 0, WINAPI, \ + (UINT cp, DWORD flags, char const* in, int inlen, wchar_t* out, \ + int outlen), \ + (cp, flags, in, inlen, out, outlen)) \ + X(WideCharToMultiByte, int, 0, WINAPI, \ + (UINT cp, DWORD flags, wchar_t const* in, int inlen, char* out, \ + int outlen, char const* dflt, LPBOOL used), \ + (cp, flags, in, inlen, out, outlen, dflt, used)) + +// Entry points whose hook does more than fail: it substitutes a +// pointer, rewrites a completion, or clamps a transfer. +#define COROSIO_FAULT_WIN_MANUAL(X) \ + X(recv) X(send) X(WSARecv) X(WSASend) X(ReadFile) X(WriteFile) \ + X(WSAIoctl) X(GetQueuedCompletionStatus) X(GetProcAddress) \ + X(FreeAddrInfoExW) X(signal) + +#define COROSIO_FAULT_WIN_ID(name, ret, failval, cc, params, args) h_##name, +#define COROSIO_FAULT_WIN_ID1(name) h_##name, + +enum hook_id +{ + COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_ID) + COROSIO_FAULT_WIN_MANUAL(COROSIO_FAULT_WIN_ID1) + hook_count +}; + +// Filled in from the first module patched; every hook forwards through +// it. Kept out of the table so a hook body needs no forward reference +// to the table's type. +proc_t reals[hook_count] = {}; + +#define COROSIO_FAULT_WIN_CALL(name, ret, cc, params) \ + reinterpret_cast(reals[h_##name]) + +#define COROSIO_FAULT_WIN_HOOK(name, ret, failval, cc, params, args) \ + ret cc hooked_##name params \ + { \ + if(should_fail(sys::name)) \ + return failval; \ + return COROSIO_FAULT_WIN_CALL(name, ret, cc, params) args; \ + } + +COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_HOOK) + +// Copy the prefix of `in` holding at most `count` bytes into `out`. +// Corosio never passes more than a handful of buffers; 64 is a hard +// ceiling checked at runtime. +DWORD truncate_wsabuf(WSABUF const* in, DWORD n, std::size_t count, + WSABUF* out) noexcept +{ + if(n > 64u) + die("fault harness: WSABUF count exceeds 64"); + DWORD m = 0; + for(; m < n && count > 0; ++m) + { + out[m] = in[m]; + if(out[m].len > count) + out[m].len = static_cast(count); + count -= out[m].len; + } + return m; +} + +int WSAAPI hooked_recv(SOCKET s, char* buf, int len, int flags) +{ + if(should_fail(sys::recv)) + return SOCKET_ERROR; + auto const real = COROSIO_FAULT_WIN_CALL(recv, int, WSAAPI, + (SOCKET, char*, int, int)); + std::size_t c = 0; + if(should_shorten(sys::recv, c)) + { + if(c == 0) + return 0; + return real(s, buf, static_cast(c) < len ? static_cast(c) + : len, flags); + } + return real(s, buf, len, flags); +} + +int WSAAPI hooked_send(SOCKET s, char const* buf, int len, int flags) +{ + if(should_fail(sys::send)) + return SOCKET_ERROR; + auto const real = COROSIO_FAULT_WIN_CALL(send, int, WSAAPI, + (SOCKET, char const*, int, int)); + std::size_t c = 0; + if(should_shorten(sys::send, c)) + { + if(c == 0) + return 0; + return real(s, buf, static_cast(c) < len ? static_cast(c) + : len, flags); + } + return real(s, buf, len, flags); +} + +int WSAAPI hooked_WSARecv(SOCKET s, LPWSABUF bufs, DWORD n, LPDWORD got, + LPDWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) +{ + if(should_fail(sys::WSARecv)) + return SOCKET_ERROR; + auto const real = COROSIO_FAULT_WIN_CALL(WSARecv, int, WSAAPI, + (SOCKET, LPWSABUF, DWORD, LPDWORD, LPDWORD, LPWSAOVERLAPPED, + LPWSAOVERLAPPED_COMPLETION_ROUTINE)); + std::size_t c = 0; + if(should_shorten(sys::WSARecv, c)) + { + // A zero-length receive completes with zero bytes even with + // data waiting, which is what the stream layer reads as EOF. + WSABUF t[64]; + DWORD m = truncate_wsabuf(bufs, n, c, t); + if(m == 0) + { + t[0].len = 0; + t[0].buf = (bufs && n > 0) ? bufs[0].buf : nullptr; + m = 1; + } + return real(s, t, m, got, flags, ov, cr); + } + return real(s, bufs, n, got, flags, ov, cr); +} + +int WSAAPI hooked_WSASend(SOCKET s, LPWSABUF bufs, DWORD n, LPDWORD sent, + DWORD flags, LPWSAOVERLAPPED ov, LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) +{ + if(should_fail(sys::WSASend)) + return SOCKET_ERROR; + auto const real = COROSIO_FAULT_WIN_CALL(WSASend, int, WSAAPI, + (SOCKET, LPWSABUF, DWORD, LPDWORD, DWORD, LPWSAOVERLAPPED, + LPWSAOVERLAPPED_COMPLETION_ROUTINE)); + std::size_t c = 0; + if(should_shorten(sys::WSASend, c)) + { + WSABUF t[64]; + DWORD m = truncate_wsabuf(bufs, n, c, t); + if(m == 0) + { + t[0].len = 0; + t[0].buf = (bufs && n > 0) ? bufs[0].buf : nullptr; + m = 1; + } + return real(s, t, m, sent, flags, ov, cr); + } + return real(s, bufs, n, sent, flags, ov, cr); +} + +BOOL WINAPI hooked_ReadFile(HANDLE h, LPVOID buf, DWORD len, LPDWORD got, + LPOVERLAPPED ov) +{ + if(should_fail(sys::ReadFile)) + return FALSE; + auto const real = COROSIO_FAULT_WIN_CALL(ReadFile, BOOL, WINAPI, + (HANDLE, LPVOID, DWORD, LPDWORD, LPOVERLAPPED)); + std::size_t c = 0; + if(should_shorten(sys::ReadFile, c)) + return real(h, buf, static_cast(c) < len + ? static_cast(c) : len, got, ov); + return real(h, buf, len, got, ov); +} + +BOOL WINAPI hooked_WriteFile(HANDLE h, LPCVOID buf, DWORD len, LPDWORD put, + LPOVERLAPPED ov) +{ + if(should_fail(sys::WriteFile)) + return FALSE; + auto const real = COROSIO_FAULT_WIN_CALL(WriteFile, BOOL, WINAPI, + (HANDLE, LPCVOID, DWORD, LPDWORD, LPOVERLAPPED)); + std::size_t c = 0; + if(should_shorten(sys::WriteFile, c)) + return real(h, buf, static_cast(c) < len + ? static_cast(c) : len, put, ov); + return real(h, buf, len, put, ov); +} + +void WSAAPI hooked_FreeAddrInfoExW(PADDRINFOEXW ai) +{ + // Nothing to report through: the arm only records that the release + // path ran, and swallowing the call would leak. + std::ignore = should_fail(sys::FreeAddrInfoExW); + COROSIO_FAULT_WIN_CALL(FreeAddrInfoExW, void, WSAAPI, (PADDRINFOEXW))(ai); +} + +using sig_handler_t = void(__cdecl*)(int); + +sig_handler_t __cdecl hooked_signal(int sig, sig_handler_t handler) +{ + if(should_fail(sys::signal)) + return SIG_ERR; + return COROSIO_FAULT_WIN_CALL(signal, sig_handler_t, __cdecl, + (int, sig_handler_t))(sig, handler); +} + +// The pointers the OS hands out rather than exports. Written once each +// by the WSAIoctl / GetProcAddress hooks; a second io_context stores +// the same value, so the race is benign. +LPFN_ACCEPTEX real_accept_ex = nullptr; +LPFN_CONNECTEX real_connect_ex = nullptr; +proc_t real_nt_set_information_file = nullptr; +proc_t real_nt_flush_buffers_file_ex = nullptr; + +BOOL PASCAL hooked_AcceptEx(SOCKET listener, SOCKET accepted, PVOID buf, + DWORD recv_len, DWORD local_len, DWORD remote_len, LPDWORD got, + LPOVERLAPPED ov) +{ + if(should_fail(sys::AcceptEx)) + return FALSE; + return real_accept_ex(listener, accepted, buf, recv_len, local_len, + remote_len, got, ov); +} + +BOOL PASCAL hooked_ConnectEx(SOCKET s, sockaddr const* name, int namelen, + PVOID buf, DWORD buf_len, LPDWORD sent, LPOVERLAPPED ov) +{ + if(should_fail(sys::ConnectEx)) + return FALSE; + return real_connect_ex(s, name, namelen, buf, buf_len, sent, ov); +} + +// The library only tests these against zero, so one non-zero NTSTATUS +// is as good as any: the arm's `err` reaches the caller through the +// last-error slot instead. +LONG const status_unsuccessful = static_cast(0xC0000001); + +LONG NTAPI hooked_NtSetInformationFile(HANDLE h, ULONG_PTR* iosb, void* info, + ULONG len, ULONG cls) +{ + if(should_fail(sys::NtSetInformationFile)) + return status_unsuccessful; + return reinterpret_cast(real_nt_set_information_file)(h, iosb, info, len, cls); +} + +LONG NTAPI hooked_NtFlushBuffersFileEx(HANDLE h, ULONG flags, void* params, + ULONG len, void* iosb) +{ + if(should_fail(sys::NtFlushBuffersFileEx)) + return status_unsuccessful; + return reinterpret_cast(real_nt_flush_buffers_file_ex)(h, flags, params, len, iosb); +} + +bool same_guid(void const* lhs, GUID const& rhs) noexcept +{ + return std::memcmp(lhs, &rhs, sizeof(GUID)) == 0; +} + +int WSAAPI hooked_WSAIoctl(SOCKET s, DWORD code, LPVOID in, DWORD inlen, + LPVOID out, DWORD outlen, LPDWORD ret, LPWSAOVERLAPPED ov, + LPWSAOVERLAPPED_COMPLETION_ROUTINE cr) +{ + if(should_fail(sys::WSAIoctl)) + return SOCKET_ERROR; + int const r = COROSIO_FAULT_WIN_CALL(WSAIoctl, int, WSAAPI, + (SOCKET, DWORD, LPVOID, DWORD, LPVOID, DWORD, LPDWORD, + LPWSAOVERLAPPED, LPWSAOVERLAPPED_COMPLETION_ROUTINE))( + s, code, in, inlen, out, outlen, ret, ov, cr); + if(r != 0 || code != SIO_GET_EXTENSION_FUNCTION_POINTER || + !in || inlen < sizeof(GUID) || !out || outlen < sizeof(void*)) + return r; + GUID const accept_ex = WSAID_ACCEPTEX; + GUID const connect_ex = WSAID_CONNECTEX; + if(same_guid(in, accept_ex)) + { + std::memcpy(&real_accept_ex, out, sizeof(real_accept_ex)); + auto const p = &hooked_AcceptEx; + std::memcpy(out, &p, sizeof(p)); + } + else if(same_guid(in, connect_ex)) + { + std::memcpy(&real_connect_ex, out, sizeof(real_connect_ex)); + auto const p = &hooked_ConnectEx; + std::memcpy(out, &p, sizeof(p)); + } + return r; +} + +FARPROC WINAPI hooked_GetProcAddress(HMODULE mod, LPCSTR name) +{ + if(should_fail(sys::GetProcAddress)) + return nullptr; + FARPROC const p = COROSIO_FAULT_WIN_CALL(GetProcAddress, FARPROC, WINAPI, + (HMODULE, LPCSTR))(mod, name); + // An ordinal import carries no string to compare. + if(!p || IS_INTRESOURCE(name)) + return p; + if(std::strcmp(name, "NtSetInformationFile") == 0) + { + real_nt_set_information_file = reinterpret_cast(p); + return reinterpret_cast( + reinterpret_cast(&hooked_NtSetInformationFile)); + } + if(std::strcmp(name, "NtFlushBuffersFileEx") == 0) + { + real_nt_flush_buffers_file_ex = reinterpret_cast(p); + return reinterpret_cast( + reinterpret_cast(&hooked_NtFlushBuffersFileEx)); + } + return p; +} + +// win_scheduler reads the dequeue's outcome as +// `SetLastError(0); r = GetQueuedCompletionStatus(...); +// err = r ? 0 : GetLastError()`, and never looks at OVERLAPPED's own +// status, so failing the call after it succeeded is what puts an error +// on a completed operation. Leaving the overlapped pointer null +// instead reaches the scheduler's own throw path. +BOOL WINAPI hooked_GetQueuedCompletionStatus(HANDLE port, LPDWORD bytes, + PULONG_PTR key, LPOVERLAPPED* ov, DWORD ms) +{ + if(should_fail(sys::GetQueuedCompletionStatus)) + { + if(ov) + *ov = nullptr; + return FALSE; + } + BOOL const r = COROSIO_FAULT_WIN_CALL(GetQueuedCompletionStatus, BOOL, + WINAPI, (HANDLE, LPDWORD, PULONG_PTR, LPOVERLAPPED*, DWORD))( + port, bytes, key, ov, ms); + unsigned long err = 0; + if(r && ov && *ov && completion_should_fail(err)) + { + ::SetLastError(static_cast(err)); + return FALSE; + } + return r; +} + +struct hook_entry +{ + char const* name; + sys which; + proc_t hook; + // Import thunks now pointing at the hook, summed over the modules + // that carry corosio code. + unsigned bound; +}; + +#define COROSIO_FAULT_WIN_ROW(name, ret, failval, cc, params, args) \ + { #name, sys::name, reinterpret_cast(&hooked_##name), 0 }, +#define COROSIO_FAULT_WIN_ROW1(name) \ + { #name, sys::name, reinterpret_cast(&hooked_##name), 0 }, + +hook_entry hooks[] = { + COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_ROW) + COROSIO_FAULT_WIN_MANUAL(COROSIO_FAULT_WIN_ROW1) +}; + +static_assert(sizeof(hooks) / sizeof(hooks[0]) == hook_count, + "the hook table and the hook ids disagree"); + +// Match by function name alone rather than by (dll, name). A given +// entry point moves between kernel32, KERNELBASE and the +// api-ms-win-core-* forwarders from one toolchain to the next, and the +// CRT's `signal` between ucrtbase, msvcrt and the api-ms-win-crt-* +// forwarders; none of the names here is exported by two unrelated DLLs. +hook_entry* find_hook(char const* name) noexcept +{ + for(auto& h : hooks) + { + if(std::strcmp(h.name, name) == 0) + return &h; + } + return nullptr; +} + +char const* module_name(HMODULE mod) noexcept +{ + static char buf[MAX_PATH + 1]; + if(!::GetModuleFileNameA(mod, buf, MAX_PATH)) + std::snprintf(buf, sizeof(buf), "", + reinterpret_cast(mod)); + return buf; +} + +// True for the Winsock DLL, whose import library is the only one here +// that binds by ordinal. +bool name_is_ws2_32(char const* dll) noexcept +{ + static char const needle[] = "ws2_32"; + for(std::size_t i = 0; i < sizeof(needle) - 1; ++i) + { + char c = dll[i]; + if(c >= 'A' && c <= 'Z') + c = static_cast(c - 'A' + 'a'); + if(c != needle[i]) + return false; + } + return true; +} + +// ws2_32.lib binds the Winsock 1.1 entry points by ordinal rather than +// by name, so their thunks carry no string for the name walk to match. +// The ordinals have been fixed since NT 4 — that is what an ordinal +// import is for — and the ones this table omits are entry points the +// harness does not hook. +char const* winsock_ordinal_name(char const* dll, unsigned ordinal) noexcept +{ + if(!name_is_ws2_32(dll)) + return nullptr; + switch(ordinal) + { + case 1: return "accept"; + case 2: return "bind"; + case 3: return "closesocket"; + case 4: return "connect"; + case 5: return "getpeername"; + case 6: return "getsockname"; + case 7: return "getsockopt"; + case 10: return "ioctlsocket"; + case 13: return "listen"; + case 16: return "recv"; + case 19: return "send"; + case 21: return "setsockopt"; + case 22: return "shutdown"; + case 23: return "socket"; + case 115: return "WSAStartup"; + case 116: return "WSACleanup"; + default: return nullptr; + } +} + +// Walk one module's import descriptors, handing every import the +// harness can identify to `f` along with the thunk that holds its +// resolved address. +template +void for_each_import(HMODULE mod, F&& f) noexcept +{ + auto* const base = reinterpret_cast(mod); + auto const* dos = reinterpret_cast(base); + if(dos->e_magic != IMAGE_DOS_SIGNATURE) + return; + auto const* nt = + reinterpret_cast(base + dos->e_lfanew); + if(nt->Signature != IMAGE_NT_SIGNATURE) + return; + auto const& dir = + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; + if(dir.VirtualAddress == 0) + return; + for(auto const* imp = reinterpret_cast( + base + dir.VirtualAddress); + imp->Name != 0; ++imp) + { + // Without the lookup table the thunks hold addresses, not + // names, and nothing here can be identified. + if(imp->OriginalFirstThunk == 0) + continue; + char const* const dll = + reinterpret_cast(base + imp->Name); + auto const* names = reinterpret_cast( + base + imp->OriginalFirstThunk); + auto* thunk = reinterpret_cast( + base + imp->FirstThunk); + for(; names->u1.AddressOfData != 0; ++names, ++thunk) + { + char const* name = nullptr; + if(IMAGE_SNAP_BY_ORDINAL(names->u1.Ordinal)) + { + name = winsock_ordinal_name(dll, static_cast( + IMAGE_ORDINAL(names->u1.Ordinal))); + } + else + { + auto const* by_name = + reinterpret_cast( + base + names->u1.AddressOfData); + name = reinterpret_cast(by_name->Name); + } + if(name) + f(name, *thunk); + } + } +} + +void patch_module(HMODULE mod) noexcept +{ + for_each_import(mod, [](char const* name, IMAGE_THUNK_DATA& thunk) + { + hook_entry* h = find_hook(name); + if(!h) + return; + auto const idx = static_cast(h - hooks); + if(!reals[idx]) + reals[idx] = reinterpret_cast(thunk.u1.Function); + DWORD old = 0; + if(!::VirtualProtect(&thunk.u1.Function, sizeof(void*), + PAGE_READWRITE, &old)) + { + char msg[192]; + std::snprintf(msg, sizeof(msg), + "fault harness: the import thunk for %s refused to become " + "writable", name); + die(msg); + } + thunk.u1.Function = reinterpret_cast(h->hook); + std::ignore = ::VirtualProtect(&thunk.u1.Function, sizeof(void*), + old, &old); + }); +} + +// Re-read the memory as it stands rather than trusting what the patch +// pass believed it wrote: a thunk that silently refused the store, or +// a second thunk for the same name that the walk skipped, would leave +// the arm dead with nothing to say so. +void verify_module(HMODULE mod, bool& ok) noexcept +{ + for_each_import(mod, [&](char const* name, IMAGE_THUNK_DATA& thunk) + { + hook_entry* h = find_hook(name); + if(!h) + return; + if(thunk.u1.Function == reinterpret_cast(h->hook)) + { + ++h->bound; + return; + } + std::fprintf(stderr, "fault harness: %s in %s is bound to %p, hook " + "is %p\n", name, module_name(mod), + reinterpret_cast(thunk.u1.Function), + reinterpret_cast(h->hook)); + ok = false; + }); +} + +// Case-insensitive substring match, hand-rolled because the CRT spells +// its wide comparison differently on every toolchain. +bool name_holds_corosio(wchar_t const* name) noexcept +{ + static wchar_t const needle[] = L"corosio"; + for(; *name; ++name) + { + std::size_t i = 0; + for(; needle[i]; ++i) + { + wchar_t c = name[i]; + if(c >= L'A' && c <= L'Z') + c = static_cast(c - L'A' + L'a'); + if(c != needle[i]) + break; + } + if(!needle[i]) + return true; + } + return false; +} + +// Every module that can hold corosio code. The IOCP backend is header +// inline, so a shared build calls the OS from two import tables: the +// DLL's, reached by plain io_context/tcp_socket through the backend's +// virtual dispatch, and the executable's, reached by the native_* +// wrappers instantiated in the test. +std::size_t collect_modules(HMODULE* out, std::size_t cap) noexcept +{ + std::size_t n = 0; + auto add = [&](HMODULE m) + { + if(!m) + return; + for(std::size_t i = 0; i < n; ++i) + { + if(out[i] == m) + return; + } + if(n < cap) + out[n++] = m; + }; + add(::GetModuleHandleW(nullptr)); + + // host_name is an ordinary exported corosio function, so the module + // owning its address is the library wherever it ended up. This + // needs no name and cannot be fooled by an unrelated module. + HMODULE lib = nullptr; + auto const* addr = + reinterpret_cast(&boost::corosio::host_name); + if(::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(addr), &lib)) + add(lib); + + // A corosio satellite (a TLS backend, say) would carry inline + // backend code of its own; a snapshot is the only way to see it. + HANDLE const snap = ::CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, 0); + if(snap != INVALID_HANDLE_VALUE) + { + MODULEENTRY32W me{}; + me.dwSize = static_cast(sizeof(me)); + if(::Module32FirstW(snap, &me)) + { + do + { + if(name_holds_corosio(me.szModule)) + add(me.hModule); + } + while(::Module32NextW(snap, &me)); + } + ::CloseHandle(snap); + } + return n; +} + +bool shared_build() noexcept +{ + HMODULE lib = nullptr; + auto const* addr = + reinterpret_cast(&boost::corosio::host_name); + if(!::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(addr), &lib)) + return false; + return lib != ::GetModuleHandleW(nullptr); +} + +int const installed = [] +{ + HMODULE mods[8]; + std::size_t const n = collect_modules(mods, sizeof(mods) / sizeof(mods[0])); + for(std::size_t i = 0; i < n; ++i) + patch_module(mods[i]); + + bool ok = true; + for(std::size_t i = 0; i < n; ++i) + verify_module(mods[i], ok); + + for(auto const& h : hooks) + { + if(h.bound != 0) + continue; + // Reported rather than fatal: what a given toolchain imports + // varies, and a name nothing in this program references has no + // thunk to patch. The self-tests skip a hook that is not live, + // so an unreported drift cannot masquerade as coverage. + std::fprintf(stderr, + "fault harness: %s is imported by no corosio module\n", h.name); + } + if(!ok) + die("fault harness: the import tables were not patched"); + return 0; +}(); + +} // namespace + +bool corosio_is_shared() noexcept +{ + return shared_build(); +} + +bool hook_is_live(sys which) noexcept +{ + switch(which) + { + // Substituted through the pointer WSAIoctl hands out. + case sys::AcceptEx: + case sys::ConnectEx: + return hooks[h_WSAIoctl].bound != 0; + // Substituted through the pointer GetProcAddress hands out. + case sys::NtSetInformationFile: + case sys::NtFlushBuffersFileEx: + return hooks[h_GetProcAddress].bound != 0; + default: + break; + } + for(auto const& h : hooks) + { + if(h.which == which) + return h.bound != 0; + } + return false; +} + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp new file mode 100644 index 000000000..c063324e5 --- /dev/null +++ b/test/unit/fault/iocp_faults.cpp @@ -0,0 +1,1197 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_IOCP + +// Some Windows SDKs still ship winsock2.h without the AF_UNIX name the +// library's own sources spell out the same way. +#ifndef AF_UNIX +#define AF_UNIX 1 +#endif + +namespace boost::corosio::test::fault { + +namespace { + +endpoint loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +// Reach win_scheduler::post(continuation&) without a heap allocation, +// which is the overload whose PostQueuedCompletionStatus failure has a +// fallback of its own. +struct post_awaitable +{ + capy::continuation* cont; + + bool await_ready() const noexcept { return false; } + + void await_suspend( + std::coroutine_handle<> h, capy::io_env const* env) noexcept + { + cont->h = h; + env->executor.post(*cont); + } + + void await_resume() const noexcept {} +}; + +// One entry point make_wakeup_pair calls, and the code to fail it with. +struct wakeup_arm +{ + sys which; + int err; +}; + +// An operation the wait reactor can no longer complete parks forever, +// and a run loop that never returns reads as a job timeout on CI +// rather than as a failure. This turns that into an assertion. +capy::task<> stop_guard(io_context& ioc, bool& expired) +{ + std::ignore = co_await corosio::delay(std::chrono::seconds(2)); + expired = true; + ioc.stop(); +} + +} // namespace + +/* Faults on the IOCP backend itself: the scheduler, its completion + dequeue, the socket services and the auxiliary wait reactor. The + file, resolver, host_name and signal paths live in win_faults.cpp. +*/ +struct iocp_faults +{ + void testSchedulerConstructFails() + { + { + // Winsock is started once per process and released when + // the last service goes, so this only fires while no + // io_context is alive (win_wsa_init.hpp:57-67). + fault_scope f(sys::WSAStartup, WSAEAFNOSUPPORT); + expect_system_error([]{ io_context ioc(iocp); }, + std::errc::address_family_not_supported); + BOOST_TEST(f.fired()); + } + { + // The scheduler's own port: CreateIoCompletionPort with + // INVALID_HANDLE_VALUE (win_scheduler.hpp:722-728). + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + expect_system_error([]{ io_context ioc(iocp); }, + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + } + } + + void testTimerCreationFails() + { + // A null waitable timer is never reported: start() returns + // without a thread and update_timeout() does nothing, so + // timers simply never fire (win_timers_thread.hpp:52,62-66). + fault_scope f(sys::CreateWaitableTimerW, ERROR_NOT_ENOUGH_MEMORY); + io_context ioc(iocp); + BOOST_TEST(f.fired()); + + bool fired_timer = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + fired_timer = true; + }; + capy::run_async(ioc.get_executor())(body()); + // Nothing else can end the loop, so the run is bounded by the + // stop rather than by the timer. + ioc.stop(); + ioc.run(); + // A smoke check rather than a proof: the stop latches before + // run() begins, so the loop would have returned early even with + // a working timer. What the arm proves is that construction + // swallowed the failure; this only shows nothing fired anyway. + BOOST_TEST(!fired_timer); + } + + void testTimerThreadWaitFails() + { + // The wait is the first thing the timer thread does, but the + // thread starts asynchronously: destroying the context right + // away can set the shutdown flag before the loop is entered + // and the wait never happens (win_timers_thread.hpp:135-141). + // So the context is held until the arm reports, bounded so a + // wait that never comes fails rather than hangs. + // + // WaitForSingleObject is not ours alone: the CRT and the + // standard library reach it through the same import thunk -- + // std::thread::join at the end of this scope certainly does, + // and a first-use initialization anywhere might. Any of those + // would spend the process-wide arm on the caller and leave + // this test passing without the timer thread ever having been + // faulted. A thread-local arm shadows the process-wide one for + // the thread that holds it, and an arm whose nth is out of + // reach never fires, so `shield` turns every wait on this + // thread into a plain forward and leaves the process-wide arm + // for the only other thread in the process. + fault_scope shield(sys::WaitForSingleObject, ERROR_INVALID_HANDLE, + (std::numeric_limits::max)()); + fault_scope f(sys::WaitForSingleObject, ERROR_INVALID_HANDLE, 1, + any_thread); + { + io_context ioc(iocp); + for(int i = 0; i < 2000 && !f.fired(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + BOOST_TEST(f.fired()); + BOOST_TEST(!shield.fired()); + } + + void testStopPostFails() + { + io_context ioc(iocp); + // The shutdown packet is the only thing that wakes a blocked + // run(), so a failed post is fatal rather than reported + // (win_scheduler.hpp:405-412). + fault_scope f(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES); + expect_system_error([&]{ ioc.stop(); }, + win_err(ERROR_NO_SYSTEM_RESOURCES)); + BOOST_TEST(f.fired()); + } + + void testPostFallbackRuns() + { + io_context ioc(iocp); + capy::continuation cont{}; + bool ran = false; + bool fired = false; + auto body = [&]() -> capy::task<> + { + { + // A failed post falls back to the allocating handle + // path, so the work still runs + // (win_scheduler.hpp:299-313). + fault_scope f(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES); + co_await post_awaitable{&cont}; + fired = f.fired(); + } + ran = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(fired); + BOOST_TEST(ran); + } + + void testRunLoopDequeueFails() + { + io_context ioc(iocp); + // A dequeue that reports failure with no OVERLAPPED is not a + // timeout, so the run loop throws (win_scheduler.hpp:663-669). + fault_scope f(sys::GetQueuedCompletionStatus, ERROR_INVALID_HANDLE); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST(f.fired()); + } + + void testTcpOpenFails() + { + io_context ioc(iocp); + { + tcp_socket s(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!s.is_open()); + } + // The socket exists when the association fails, so the + // failure path owns closing it. + expect_no_handle_leak([&]{ + tcp_socket s(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!s.is_open()); + }); + } + + void testTcpAssignFails() + { + io_context ioc(iocp); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + // SO_PROTOCOL_INFOW is how adoption learns the family + // and type (win_tcp_acceptor_service.hpp:1141-1147). + tcp_socket s(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_a_socket); + BOOST_TEST(!s.is_open()); + } + { + tcp_socket s(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!s.is_open()); + } + }); + // A rejected adoption leaves the socket with the caller. + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + + void testTcpBindFails() + { + io_context ioc(iocp); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::bind, WSAEADDRINUSE); + auto ec = s.bind(loopback()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_in_use); + BOOST_TEST(s.is_open()); + } + + void testTcpOptionsFail() + { + io_context ioc(iocp); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + { + fault_scope f(sys::setsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ s.set_option(socket_option::reuse_address(true)); }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + { + fault_scope f(sys::getsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ + std::ignore = s.get_option(); + }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + { + fault_scope f(sys::shutdown, WSAENOTSOCK); + BOOST_TEST(s.shutdown(tcp_socket::shutdown_both) == + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + } + + void testTcpReleaseIgnoresDissociate() + { + if(!hook_is_live(sys::NtSetInformationFile)) + { + skip_dead_hook("NtSetInformationFile"); + return; + } + io_context ioc(iocp); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + // Severing the port association is best effort: the caller + // gets a working socket either way + // (win_tcp_acceptor_service.hpp:986-996). + fault_scope f(sys::NtSetInformationFile, ERROR_INVALID_PARAMETER); + auto h = s.release(); + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + + void testTcpExtensionPointerMissing() + { + // load_extension_functions runs once, from the tcp service's + // constructor, so the arm has to precede the io_context + // (win_tcp_acceptor_service.hpp:1241-1264). + fault_scope f(sys::WSAIoctl, WSAEOPNOTSUPP); + io_context ioc(iocp); + BOOST_TEST(f.fired()); + + tcp_acceptor acc(ioc, loopback()); + auto port = acc.local_endpoint().port(); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + std::error_code cec; + auto body = [&]() -> capy::task<> + { + auto [ec] = co_await s.connect( + endpoint(ipv4_address::loopback(), port)); + cec = ec; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(cec == std::errc::operation_not_supported); + } + + void testTcpConnectFails() + { + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + auto const ep = endpoint( + ipv4_address::loopback(), acc.local_endpoint().port()); + std::error_code bec, sec, cec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + // ConnectEx needs a bound socket, so an unbound one + // is bound to the wildcard first + // (win_tcp_acceptor_service.hpp:507-536). + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::bind, WSAEADDRNOTAVAIL); + auto [ec] = co_await s.connect(ep); + bec = ec; + BOOST_TEST(f.fired()); + } + if(hook_is_live(sys::ConnectEx)) + { + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::ConnectEx, WSAECONNREFUSED); + auto [ec] = co_await s.connect(ep); + sec = ec; + BOOST_TEST(f.fired()); + } + else + { + skip_dead_hook("ConnectEx"); + } + { + // The kernel result of a queued connect only exists + // on the completion. + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + completion_fault_scope q(ERROR_CONNECTION_REFUSED); + auto [ec] = co_await s.connect(ep); + cec = ec; + BOOST_TEST(q.fired()); + } + 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(bec == std::errc::address_not_available); + if(hook_is_live(sys::ConnectEx)) + BOOST_TEST(sec == std::errc::connection_refused); + BOOST_TEST(cec == std::errc::connection_refused); + } + + void testTcpReadWriteFails() + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& a = pair.first; + auto& b = pair.second; + char buf[8] = {}; + char out[4] = "abc"; + std::error_code rec, wec, rcec, wcec, eec, wtec; + std::size_t en = 99; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::WSARecv, WSAENOTSOCK); + auto [ec, n] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::WSASend, WSAENOTSOCK); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + // A wait for readability is a zero-byte WSARecv + // (win_tcp_acceptor_service.hpp:740-756). + fault_scope f(sys::WSARecv, WSAENOTSOCK); + auto [ec] = co_await a.wait(wait_type::read); + wtec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await b.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + // A remote reset reaches a pending read as + // ERROR_NETNAME_DELETED, which off the accept path + // means connection_reset + // (win_overlapped_op.hpp:76-81). + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rcec = ec; + BOOST_TEST(q.fired()); + } + { + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + wcec = ec; + BOOST_TEST(q.fired()); + } + { + auto [ec, n] = co_await b.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + // A receive shortened to nothing completes with zero + // bytes even with data waiting, which the stream + // contract reads as end of file. + auto f = fault_scope::returning(sys::WSARecv, 0); + auto [ec, n] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + eec = ec; + en = n; + BOOST_TEST(f.fired()); + } + a.cancel(); + b.cancel(); + 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(rec == std::errc::not_a_socket); + BOOST_TEST(wec == std::errc::not_a_socket); + BOOST_TEST(wtec == std::errc::not_a_socket); + BOOST_TEST(rcec == std::errc::connection_reset); + BOOST_TEST(wcec == std::errc::connection_reset); + BOOST_TEST(eec == capy::error::eof); + BOOST_TEST_EQ(en, 0u); + } + + void testAcceptorOpenFails() + { + io_context ioc(iocp); + { + tcp_acceptor acc(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!acc.is_open()); + } + expect_no_handle_leak([&]{ + tcp_acceptor acc(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!acc.is_open()); + }); + { + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + fault_scope f(sys::bind, WSAEADDRINUSE); + BOOST_TEST(acc.bind(loopback()) == std::errc::address_in_use); + BOOST_TEST(f.fired()); + } + { + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(loopback())); + fault_scope f(sys::listen, WSAEOPNOTSUPP); + BOOST_TEST(acc.listen() == std::errc::operation_not_supported); + BOOST_TEST(f.fired()); + } + { + // The convenience constructor reports the same codes by + // throwing. + fault_scope f(sys::listen, WSAEOPNOTSUPP); + expect_system_error([&]{ tcp_acceptor acc(ioc, loopback()); }, + std::errc::operation_not_supported); + BOOST_TEST(f.fired()); + } + } + + void testAcceptFails() + { + io_context ioc(iocp); + tcp_acceptor acc(ioc, loopback()); + auto const ep = endpoint( + ipv4_address::loopback(), acc.local_endpoint().port()); + std::error_code sockec, portec, syncec, compec, waitec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + tcp_socket server(ioc); + { + // Writability carries no meaning for a listening + // socket and reaches no syscall + // (win_tcp_acceptor_service.hpp:1570-1574). + auto [ec] = co_await acc.wait(wait_type::write); + waitec = ec; + } + { + tcp_socket client(ioc); + auto [cec] = co_await client.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto [ec] = co_await acc.accept(server); + sockec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + client.cancel(); + client.close(); + } + { + tcp_socket client(ioc); + auto [cec] = co_await client.connect(ep); + BOOST_TEST(!cec); + // No handle-count assertion here: the client socket + // this accept needs is created inside the same window, + // so the count carries more than the accept's own + // bookkeeping. + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto [ec] = co_await acc.accept(server); + portec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + client.cancel(); + client.close(); + } + if(hook_is_live(sys::AcceptEx)) + { + tcp_socket client(ioc); + auto [cec] = co_await client.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::AcceptEx, WSAENOTSOCK); + auto [ec] = co_await acc.accept(server); + syncec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + client.cancel(); + client.close(); + } + else + { + skip_dead_hook("AcceptEx"); + } + { + tcp_socket client(ioc); + auto [cec] = co_await client.connect(ep); + BOOST_TEST(!cec); + // On the accept path ERROR_NETNAME_DELETED means the + // half-open connection died, not a reset stream + // (win_overlapped_op.hpp:76-81). + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec] = co_await acc.accept(server); + compec = ec; + BOOST_TEST(q.fired()); + BOOST_TEST(!server.is_open()); + client.cancel(); + client.close(); + } + 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(waitec == std::errc::operation_not_supported); + BOOST_TEST(sockec == std::errc::address_family_not_supported); + BOOST_TEST(portec == win_err(ERROR_INVALID_PARAMETER)); + if(hook_is_live(sys::AcceptEx)) + BOOST_TEST(syncec == std::errc::not_a_socket); + BOOST_TEST(compec == std::errc::connection_aborted); + } + + void testUdpSetupFails() + { + io_context ioc(iocp); + { + udp_socket u(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = u.open(udp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!u.is_open()); + } + expect_no_handle_leak([&]{ + udp_socket u(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = u.open(udp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!u.is_open()); + }); + { + udp_socket u(ioc); + BOOST_TEST(!u.open(udp::v4())); + fault_scope f(sys::bind, WSAEADDRINUSE); + BOOST_TEST(u.bind(loopback()) == std::errc::address_in_use); + BOOST_TEST(f.fired()); + BOOST_TEST(u.is_open()); + } + { + auto h = make_native_socket(AF_INET, SOCK_DGRAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + udp_socket u(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + BOOST_TEST(u.assign(h) == std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(!u.is_open()); + } + { + udp_socket u(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST( + u.assign(h) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!u.is_open()); + } + }); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + { + udp_socket u(ioc); + BOOST_TEST(!u.open(udp::v4())); + { + fault_scope f(sys::setsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ u.set_option(socket_option::reuse_address(true)); }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::getsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ + std::ignore = + u.get_option(); + }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::shutdown, WSAENOTSOCK); + BOOST_TEST(u.shutdown(udp_socket::shutdown_both) == + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + } + } + + void testUdpIoFails() + { + io_context ioc(iocp); + udp_socket a(ioc), b(ioc); + BOOST_TEST(!a.open(udp::v4())); + BOOST_TEST(!a.bind(loopback())); + BOOST_TEST(!b.open(udp::v4())); + BOOST_TEST(!b.bind(loopback())); + auto const a_ep = endpoint( + ipv4_address::loopback(), a.local_endpoint().port()); + auto const b_ep = endpoint( + ipv4_address::loopback(), b.local_endpoint().port()); + char buf[8] = {}; + char out[4] = "abc"; + std::error_code stec, rfec, stcec, rfcec, conec, sec, rec, scec, rcec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::WSASendTo, WSAENOTSOCK); + auto [ec, n] = co_await a.send_to( + capy::const_buffer(out, 3), b_ep); + std::ignore = n; + stec = ec; + BOOST_TEST(f.fired()); + } + { + endpoint src; + fault_scope f(sys::WSARecvFrom, WSAENOTSOCK); + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), src); + std::ignore = n; + rfec = ec; + BOOST_TEST(f.fired()); + } + { + // The kernel result of a queued WSASendTo only exists + // on the completion, so the unconnected send has its + // own completion fault. + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await a.send_to( + capy::const_buffer(out, 3), b_ep); + std::ignore = n; + stcec = ec; + BOOST_TEST(q.fired()); + } + { + // That datagram really went out — only its completion + // was rewritten — so the receive has a real one to + // fail in turn. + endpoint src; + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), src); + std::ignore = n; + rfcec = ec; + BOOST_TEST(q.fired()); + } + { + // A datagram connect is synchronous: WSAConnect + // either names the peer or reports why not + // (win_udp_service.hpp:566-576). + fault_scope f(sys::WSAConnect, WSAEAFNOSUPPORT); + auto [ec] = co_await a.connect(b_ep); + conec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec] = co_await a.connect(b_ep); + BOOST_TEST(!ec); + } + { + auto [ec] = co_await b.connect(a_ep); + BOOST_TEST(!ec); + } + { + fault_scope f(sys::WSASend, WSAENOTSOCK); + auto [ec, n] = co_await a.send(capy::const_buffer(out, 3)); + std::ignore = n; + sec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::WSARecv, WSAENOTSOCK); + auto [ec, n] = co_await b.recv( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await a.send(capy::const_buffer(out, 3)); + std::ignore = n; + scec = ec; + BOOST_TEST(q.fired()); + } + { + // The datagram sent above is already queued, so the + // receive has a real completion to fail. + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec, n] = co_await b.recv( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rcec = ec; + BOOST_TEST(q.fired()); + } + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + // A receive parks until its datagram lands; the guard turns a + // dropped one into a failure instead of a stalled run loop. + BOOST_TEST(!expired); + BOOST_TEST(stec == std::errc::not_a_socket); + BOOST_TEST(rfec == std::errc::not_a_socket); + BOOST_TEST(stcec == std::errc::connection_reset); + BOOST_TEST(rfcec == std::errc::connection_reset); + BOOST_TEST(conec == std::errc::address_family_not_supported); + BOOST_TEST(sec == std::errc::not_a_socket); + BOOST_TEST(rec == std::errc::not_a_socket); + BOOST_TEST(scec == std::errc::connection_reset); + BOOST_TEST(rcec == std::errc::connection_reset); + } + + void testWaitReactorSetupFails() + { + // make_wakeup_pair reports nothing: a failure leaves the + // reactor with no self-pipe, so a register that follows never + // reaches its poll set (win_wait_reactor.hpp:159-215). Nothing + // else is observable, and poll() is used rather than run() so + // a parked op cannot hang the suite. + static constexpr wakeup_arm arms[] = { + {sys::socket, WSAEMFILE}, + {sys::bind, WSAEADDRINUSE}, + {sys::listen, WSAEOPNOTSUPP}, + {sys::connect, WSAECONNREFUSED}, + {sys::accept, WSAENOTSOCK}, + }; + for(auto const& a : arms) + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + // The arm outlives the coroutine: a wait the reactor + // cannot report never resumes it, so `fired()` has to be + // readable from here. + std::optional arm; + auto body = [&]() -> capy::task<> + { + arm.emplace(a.which, a.err, 1u); + std::ignore = co_await s1.wait(wait_type::write); + }; + capy::run_async(ioc.get_executor())(body()); + std::ignore = ioc.poll(); + s1.cancel(); + std::ignore = ioc.poll(); + // The reactor is built inside the wait above, on this + // thread, so the arm is settled by the time poll returns. + BOOST_TEST(arm.has_value() && arm->fired()); + arm.reset(); + s1.close(); + s2.close(); + } + } + + void testWaitReactorPollFails() + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::optional arm; + std::error_code parked_ec; + bool expired = false; + auto parked = [&]() -> capy::task<> + { + // An error wait on a quiet socket never becomes ready, so + // the only thing that can complete it is the reactor + // leaving its loop. + auto [ec] = co_await s1.wait(wait_type::error); + parked_ec = ec; + ioc.stop(); + }; + auto breaker = [&]() -> capy::task<> + { + // Armed after the wait above is queued, so whichever poll + // fails first already has that op in hand: the drain on + // the way out covers registered_ and pending_register_ + // alike (win_wait_reactor.hpp:344-348, 400-412). + 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; + }; + capy::run_async(ioc.get_executor())(parked()); + capy::run_async(ioc.get_executor())(breaker()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(arm.has_value() && arm->fired()); + BOOST_TEST(parked_ec == capy::error::canceled); + arm.reset(); + s1.close(); + s2.close(); + } + + void testLocalSetupFails() + { + io_context ioc(iocp); + { + local_stream_socket s(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = s.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!s.is_open()); + } + expect_no_handle_leak([&]{ + local_stream_socket s(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = s.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!s.is_open()); + }); + { + local_stream_acceptor acc(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!acc.is_open()); + } + expect_no_handle_leak([&]{ + local_stream_acceptor acc(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!acc.is_open()); + }); + { + auto h = make_native_socket(AF_UNIX, SOCK_STREAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + // Adoption learns the family and type from + // SO_PROTOCOL_INFOW here too + // (win_local_stream_service.hpp:990-1000). + local_stream_socket s(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + BOOST_TEST(s.assign(h) == std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + } + { + local_stream_socket s(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST( + s.assign(h) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + } + }); + // A rejected adoption leaves the socket with the caller. + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + temp_socket_dir dir; + auto const ep = corosio::local_endpoint(dir.path()); + { + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + // The unlink that precedes a bind is best effort: a + // missing file is the common case + // (local_stream_acceptor::bind). + fault_scope f(sys::DeleteFileA, ERROR_ACCESS_DENIED); + BOOST_TEST(!acc.bind(ep, bind_option::unlink_existing)); + BOOST_TEST(f.fired()); + BOOST_TEST(acc.is_open()); + } + { + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + fault_scope f(sys::bind, WSAEADDRINUSE); + BOOST_TEST(acc.bind(ep) == std::errc::address_in_use); + BOOST_TEST(f.fired()); + BOOST_TEST(acc.is_open()); + } + { + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(ep, bind_option::unlink_existing)); + fault_scope f(sys::listen, WSAEOPNOTSUPP); + BOOST_TEST(acc.listen() == std::errc::operation_not_supported); + BOOST_TEST(f.fired()); + // Not latched: the acceptor is still bound and open, so a + // second listen takes. + BOOST_TEST(acc.is_open()); + BOOST_TEST(!acc.listen()); + } + } + + void testLocalConnectAcceptFails() + { + io_context ioc(iocp); + temp_socket_dir dir; + auto const ep = corosio::local_endpoint(dir.path()); + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(ep, bind_option::unlink_existing)); + BOOST_TEST(!acc.listen()); + std::error_code bec, sockec, portec, syncec, compec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + // AF_UNIX ConnectEx also needs a bound socket, which + // it satisfies with a family-only sockaddr_un + // (win_local_stream_service.hpp:419-434). + local_stream_socket s(ioc); + BOOST_TEST(!s.open()); + fault_scope f(sys::bind, WSAEADDRNOTAVAIL); + auto [ec] = co_await s.connect(ep); + bec = ec; + BOOST_TEST(f.fired()); + } + local_stream_socket peer(ioc); + { + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [cec] = co_await c.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto [ec] = co_await acc.accept(peer); + sockec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!peer.is_open()); + c.cancel(); + c.close(); + } + { + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [cec] = co_await c.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto [ec] = co_await acc.accept(peer); + portec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!peer.is_open()); + c.cancel(); + c.close(); + } + if(hook_is_live(sys::AcceptEx)) + { + // The AF_UNIX accept reaches the same substituted + // pointer as the tcp one + // (win_local_stream_acceptor_service.hpp:415-425). + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [cec] = co_await c.connect(ep); + BOOST_TEST(!cec); + fault_scope f(sys::AcceptEx, WSAENOTSOCK); + auto [ec] = co_await acc.accept(peer); + syncec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!peer.is_open()); + c.cancel(); + c.close(); + } + else + { + skip_dead_hook("AcceptEx"); + } + { + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [cec] = co_await c.connect(ep); + BOOST_TEST(!cec); + completion_fault_scope q(ERROR_NETNAME_DELETED); + auto [ec] = co_await acc.accept(peer); + compec = ec; + BOOST_TEST(q.fired()); + BOOST_TEST(!peer.is_open()); + c.cancel(); + c.close(); + } + 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(bec == std::errc::address_not_available); + BOOST_TEST(sockec == std::errc::address_family_not_supported); + BOOST_TEST(portec == win_err(ERROR_INVALID_PARAMETER)); + if(hook_is_live(sys::AcceptEx)) + BOOST_TEST(syncec == std::errc::not_a_socket); + BOOST_TEST(compec == std::errc::connection_aborted); + } + + void run() + { + testSchedulerConstructFails(); + testTimerCreationFails(); + testTimerThreadWaitFails(); + testStopPostFails(); + testPostFallbackRuns(); + testRunLoopDequeueFails(); + testTcpOpenFails(); + testTcpAssignFails(); + testTcpBindFails(); + testTcpOptionsFail(); + testTcpReleaseIgnoresDissociate(); + testTcpExtensionPointerMissing(); + testTcpConnectFails(); + testTcpReadWriteFails(); + testAcceptorOpenFails(); + testAcceptFails(); + testUdpSetupFails(); + testUdpIoFails(); + testWaitReactorSetupFails(); + testWaitReactorPollFails(); + testLocalSetupFails(); + testLocalConnectAcceptFails(); + } +}; + +TEST_SUITE(iocp_faults, "boost.corosio.fault.iocp"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/kqueue_faults.cpp b/test/unit/fault/kqueue_faults.cpp new file mode 100644 index 000000000..3c08baddf --- /dev/null +++ b/test/unit/fault/kqueue_faults.cpp @@ -0,0 +1,412 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_KQUEUE + +namespace boost::corosio::test::fault { + +namespace { + +endpoint loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +} // namespace + +/* kqueue-specific fault coverage. + + Every registration, interruption and wait on this backend is one + symbol, kevent(), so an arm's `nth` has to be read against the + syscall order of the operation under test rather than against a + symbol that only the run loop uses. The scheduler issues exactly one + kevent() while constructing (the EVFILT_USER registration), so a + scope armed after the io_context exists starts counting at the next + one. +*/ +struct kqueue_faults +{ + void testConstructorFails() + { + auto expect_throw = [](sys s, unsigned nth, int err, std::errc code) + { + int before = open_fds(); + fault_scope f(s, err, nth); + expect_system_error([&]{ io_context ioc(kqueue); }, code); + BOOST_TEST(f.fired()); + // Every constructor failure past kqueue() closes the queue + // before throwing (the kqueue_scheduler constructor). + BOOST_TEST_EQ(open_fds(), before); + }; + expect_throw(sys::kqueue, 1, EMFILE, + std::errc::too_many_files_open); + // The only fcntl the scheduler makes is FD_CLOEXEC on the queue. + expect_throw(sys::fcntl, 1, EINVAL, + std::errc::invalid_argument); + // 1 is the EVFILT_USER registration used to interrupt the wait. + expect_throw(sys::kevent, 1, ENOMEM, + std::errc::not_enough_memory); + } + + void testOpenFails() + { + io_context ioc(kqueue); + { + tcp_socket s(ioc); + fault_scope f(sys::socket, EMFILE); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + } + // F_GETFL, F_SETFL(O_NONBLOCK) and F_SETFD(FD_CLOEXEC), in that + // order (kqueue_traits::set_fd_options). + for(unsigned nth : {1u, 2u, 3u}) + { + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + { + // SO_NOSIGPIPE is the one setsockopt an AF_INET open + // makes, and it is fatal: this backend's write policy + // spells its writes writev() (write_policy::write) and + // write() (write_policy::write_one), neither of which carries + // per-call SIGPIPE suppression, so the socket-level flag + // is the only guard. + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::no_protocol_option); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + { + // The socket already exists when registration fails, so the + // failure path owns closing it. + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::kevent, ENOMEM); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_enough_memory); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testAssignRegisterFails() + { + io_context ioc(kqueue); + int before = open_fds(); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + make_native_adoptable(h); + { + // assign rolls its own registration failure back rather + // than closing anything: fd_ and registered_events return + // to their closed values and the caller keeps the + // descriptor it passed in + // (reactor_basic_socket::init_and_register). + tcp_socket s(ioc); + int held = open_fds(); + fault_scope f(sys::kevent, ENOMEM); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_enough_memory); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), held); + BOOST_TEST(native_socket_valid(h)); + } + close_native_socket(h); + BOOST_TEST_EQ(open_fds(), before); + } + + void testAcceptorRegisterFails() + { + io_context ioc(kqueue); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(loopback())); + // An acceptor reaches register_descriptor for the first time at + // listen (reactor_acceptor::do_listen); open only creates and + // configures the fd. + fault_scope f(sys::kevent, ENOMEM); + BOOST_TEST(acc.listen() == std::errc::not_enough_memory); + BOOST_TEST(f.fired()); + // Not latched: registered_events stayed 0, so a second listen + // re-registers (kqueue_scheduler::register_descriptor). + BOOST_TEST(!acc.listen()); + } + + void testAcceptFails() + { + io_context ioc(kqueue); + tcp_acceptor acc(ioc, loopback()); + std::error_code aec, aec2; + int leaked = 0; + auto body = [&]() -> capy::task<> + { + tcp_socket client(ioc), server(ioc); + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // EINTR is retried inside accept_policy, then the real + // accept succeeds (accept_policy::do_accept). + fault_scope f(sys::accept, EINTR); + auto [ec] = co_await acc.accept(server); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + } + server.close(); + client.close(); + tcp_socket client2(ioc); + { + auto [ec] = co_await client2.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // The faulted accept never ran, so the queued connection + // survives for the retry below. + fault_scope f(sys::accept, ECONNABORTED); + auto [ec] = co_await acc.accept(server); + aec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + } + { + auto [ec] = co_await acc.accept(server); + BOOST_TEST(!ec); + } + server.close(); + client2.close(); + { + // The accepted fd fails to register: the impl is + // destroyed, which closes it, and the error is reported + // (reactor_acceptor_impl::accept). + tcp_socket client3(ioc); + auto [cec] = co_await client3.connect(acc.local_endpoint()); + BOOST_TEST(!cec); + int before = open_fds(); + fault_scope f(sys::kevent, ENOMEM); + auto [ec] = co_await acc.accept(server); + aec2 = ec; + leaked = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + client3.close(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(aec == std::errc::connection_aborted); + BOOST_TEST(aec2 == std::errc::not_enough_memory); + BOOST_TEST_EQ(leaked, 0); + } + + void testAcceptConfigureFails() + { + io_context ioc(kqueue); + tcp_acceptor acc(ioc, loopback()); + std::error_code fcntl_ecs[3], sockopt_ec; + int fcntl_leaked[3] = {}, sockopt_leaked = 0; + auto body = [&]() -> capy::task<> + { + // Each failure closes the descriptor accept() already + // handed back, consuming the queued connection, so every + // iteration needs a client of its own. + for(unsigned nth = 1; nth <= 3; ++nth) + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + int before = open_fds(); + // Armed after the connect, so 1..3 are the F_GETFL, + // F_SETFL and F_SETFD accept_policy makes on the + // accepted fd (accept_policy::do_accept). + fault_scope f(sys::fcntl, EINVAL, nth); + auto [ec] = co_await acc.accept(s); + fcntl_ecs[nth - 1] = ec; + fcntl_leaked[nth - 1] = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + c.close(); + } + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + int before = open_fds(); + // SO_NOSIGPIPE on the accepted fd, fatal for the + // same reason (accept_policy::do_accept). + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto [ec] = co_await acc.accept(s); + sockopt_ec = ec; + sockopt_leaked = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + c.close(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + for(unsigned i = 0; i < 3; ++i) + { + BOOST_TEST(fcntl_ecs[i] == std::errc::invalid_argument); + BOOST_TEST_EQ(fcntl_leaked[i], 0); + } + BOOST_TEST(sockopt_ec == std::errc::no_protocol_option); + BOOST_TEST_EQ(sockopt_leaked, 0); + } + + void testRunLoopFaults() + { + // Nothing calls kevent between the constructor and the run + // loop's first wait: posting the task does not interrupt a + // reactor that is not running yet + // (reactor_scheduler::wake_one_thread_and_unlock), so nth 1 is + // that wait. + { + io_context ioc(kqueue); + fault_scope f(sys::kevent, EINTR); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(done); + } + { + // Any other errno leaves run() through an exception + // (kqueue_scheduler::run_task). + io_context ioc(kqueue); + fault_scope f(sys::kevent, EBADF); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + } + } + + void testInterruptTriggerIgnored() + { + io_context ioc(kqueue); + { + // stop() interrupts unconditionally + // (reactor_scheduler.hpp:566-573), which is the one path + // that reaches NOTE_TRIGGER without a reactor thread. The + // result is discarded, and user_event_armed_ stays latched + // even though nothing was ever queued on the kqueue + // (kqueue_scheduler.hpp:285-293) — so no later interrupt + // can wake a blocked wait either. + fault_scope f(sys::kevent, EIO); + ioc.stop(); + BOOST_TEST(f.fired()); + } + // What survives the latch is the timeout: the wait is computed + // from the nearest expiry, so timed work still completes and + // run() still returns. Asserting anything about a wait that + // only an interrupt could end would be asserting a hang. + ioc.restart(); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(done); + } + + void testSignalReaderRegisterFails() + { + in_child([]{ + io_context ioc(kqueue); + signal_set ss(ioc); + std::error_code ec; + bool fired = false; + { + // The self-pipe's pipe() and six fcntl() calls come + // first; the kevent is the reader registration + // (posix_signal_service::add_signal). + fault_scope f(sys::kevent, ENOMEM); + ec = ss.add(SIGUSR2); + fired = f.fired(); + } + // Not latched: the next add retries the registration. + return fired && ec == std::errc::not_enough_memory && + !ss.add(SIGUSR2) && !ss.clear(); + }); + } + + void run() + { + if(skip_under_valgrind()) + return; + testConstructorFails(); + testOpenFails(); + testAssignRegisterFails(); + testAcceptorRegisterFails(); + testAcceptFails(); + testAcceptConfigureFails(); + testRunLoopFaults(); + testInterruptTriggerIgnored(); + testSignalReaderRegisterFails(); + } +}; + +TEST_SUITE(kqueue_faults, "boost.corosio.fault.kqueue"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/posix_faults.cpp b/test/unit/fault/posix_faults.cpp new file mode 100644 index 000000000..145819e4d --- /dev/null +++ b/test/unit/fault/posix_faults.cpp @@ -0,0 +1,550 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace boost::corosio::test::fault { + +template +struct posix_common_faults +{ + // The io_uring backend has its own file implementation: reads and + // writes go through the ring instead of preadv/pwritev, seek uses + // lseek instead of fstat, and open never stats an appending file. +#if BOOST_COROSIO_HAS_IO_URING + static constexpr bool ring_files = std::is_same_v< + std::remove_cvref_t, io_uring_t>; +#else + static constexpr bool ring_files = false; +#endif + + // macOS has no fdatasync, so sync_data() lowers to fsync there + // (posix_stream_file::sync_data and + // posix_random_access_file::sync_data). Keying off the library's + // own macro rather than __APPLE__ keeps the two in step. +#if BOOST_COROSIO_HAS_POSIX_SYNCHRONIZED_IO + static constexpr sys sync_data_call = sys::fdatasync; +#else + static constexpr sys sync_data_call = sys::fsync; +#endif + + // Functional probe: proves the library's own socket() call binds + // to the hook on this backend, whatever the link mode. + void testSocketOpenFails() + { + io_context ioc(Backend); + tcp_socket s(ioc); + fault_scope f(sys::socket, EMFILE); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + } + + void testBindFails() + { + io_context ioc(Backend); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::bind, EADDRINUSE); + auto ec = s.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_in_use); + BOOST_TEST(s.is_open()); + } + + void testSetOptionFails() + { + io_context ioc(Backend); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + expect_system_error( + [&]{ s.set_option(socket_option::reuse_address(true)); }, + std::errc::no_protocol_option); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + + void testGetOptionFails() + { + io_context ioc(Backend); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + fault_scope f(sys::getsockopt, ENOPROTOOPT); + expect_system_error( + [&]{ std::ignore = s.get_option(); }, + std::errc::no_protocol_option); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + + void testAssignValidateFails() + { + io_context ioc(Backend); + int before = open_fds(); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + make_native_adoptable(h); + { + tcp_socket s(ioc); + fault_scope f(sys::getsockname, EBADF); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::bad_file_descriptor); + BOOST_TEST(!s.is_open()); + } + { + tcp_socket s(ioc); + fault_scope f(sys::getsockopt, EBADF); + auto ec = s.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::bad_file_descriptor); + BOOST_TEST(!s.is_open()); + } + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + BOOST_TEST_EQ(open_fds(), before); + } + + void testConnectPairFails() + { + io_context ioc(Backend); + int before = open_fds(); + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::socketpair, EMFILE); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + // 1 is the F_GETFL probe, 2 the F_SETFL that follows it. + for(unsigned nth : {1u, 2u}) + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + BOOST_TEST_EQ(open_fds(), before); + } + + void testAvailableThrows() + { + io_context ioc(Backend); + local_stream_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + fault_scope f(sys::ioctl, ENOTTY); + expect_system_error( + [&]{ std::ignore = a.available(); }, + std::errc::inappropriate_io_control_operation); + BOOST_TEST(f.fired()); + BOOST_TEST(a.is_open()); + } + + void testHostNameFails() + { + fault_scope f(sys::gethostname, EPERM); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::operation_not_permitted); + BOOST_TEST(name.empty()); + } + + void testStreamFileOpenFails() + { + io_context ioc(Backend); + auto path = temp_path("sf"); + stream_file sf(ioc); + { + fault_scope f(sys::open, EACCES); + auto ec = sf.open(path, file_base::read_write | file_base::create); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::permission_denied); + BOOST_TEST(!sf.is_open()); + } + // Only the POSIX backend seeds its own offset with fstat when + // opening for append; the ring backend leaves that to O_APPEND. + if constexpr(!ring_files) + { + int before = open_fds(); + fault_scope f(sys::fstat, EIO); + auto ec = sf.open(path, file_base::write_only | + file_base::create | file_base::append); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::io_error); + BOOST_TEST(!sf.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + ::unlink(path.c_str()); + } + + void testStreamFileSyncOps() + { + io_context ioc(Backend); + auto path = temp_path("sf2"); + stream_file sf(ioc); + BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + { + fault_scope f(sys::fstat, EIO); + expect_system_error( + [&]{ std::ignore = sf.size(); }, std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::ftruncate, EFBIG); + BOOST_TEST(sf.resize(16) == std::errc::file_too_large); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sync_data_call, EIO); + BOOST_TEST(sf.sync_data() == std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::fsync, EIO); + BOOST_TEST(sf.sync_all() == std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + constexpr sys seek_end_call = ring_files ? sys::lseek : sys::fstat; + fault_scope f(seek_end_call, EIO); + auto [ec, pos] = sf.seek(0, file_base::seek_end); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::io_error); + BOOST_TEST_EQ(pos, 0u); + } + sf.close(); + ::unlink(path.c_str()); + } + + void testStreamFileIoFails() + { + // Ring file I/O never reaches preadv/pwritev; its completion + // faults belong with the other io_uring coverage. + if constexpr(ring_files) + return; + io_context ioc(Backend); + auto path = temp_path("sf3"); + stream_file sf(ioc); + BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + char buf[8] = "1234567"; + std::error_code rec, wec, eec; + std::size_t rn = 99, en = 99; + auto t = [&]() -> capy::task<> + { + // The syscall runs on a pool thread, so the arm has to be + // visible outside this one. + { + fault_scope f(sys::pwritev, ENOSPC, 1, any_thread); + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + auto [ec, pos] = sf.seek(0, file_base::seek_set); + std::ignore = pos; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::preadv, EIO, 1, any_thread); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + rec = ec; + rn = n; + BOOST_TEST(f.fired()); + } + { + auto f = fault_scope::returning_any_thread(sys::preadv, 0); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + eec = ec; + en = n; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(wec == std::errc::no_space_on_device); + BOOST_TEST(rec == std::errc::io_error); + BOOST_TEST_EQ(rn, 0u); + BOOST_TEST(eec == capy::error::eof); + BOOST_TEST_EQ(en, 0u); + sf.close(); + ::unlink(path.c_str()); + } + + void testRandomAccessFileFails() + { + io_context ioc(Backend); + auto path = temp_path("raf"); + random_access_file rf(ioc); + { + fault_scope f(sys::open, EACCES); + BOOST_TEST(rf.open(path, file_base::read_write | + file_base::create) == std::errc::permission_denied); + BOOST_TEST(f.fired()); + } + BOOST_TEST(!rf.open(path, file_base::read_write | file_base::create)); + { + fault_scope f(sys::fstat, EIO); + expect_system_error( + [&]{ std::ignore = rf.size(); }, std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::ftruncate, EFBIG); + BOOST_TEST(rf.resize(16) == std::errc::file_too_large); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sync_data_call, EIO); + BOOST_TEST(rf.sync_data() == std::errc::io_error); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::fsync, EIO); + BOOST_TEST(rf.sync_all() == std::errc::io_error); + BOOST_TEST(f.fired()); + } + // Ring file I/O never reaches preadv/pwritev; its completion + // faults belong with the other io_uring coverage. + if constexpr(ring_files) + { + rf.close(); + ::unlink(path.c_str()); + return; + } + char buf[8] = "1234567"; + std::error_code rec, wec, eec; + auto t = [&]() -> capy::task<> + { + { + fault_scope f(sys::pwritev, ENOSPC, 1, any_thread); + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::preadv, EIO, 1, any_thread); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + auto f = fault_scope::returning_any_thread(sys::preadv, 0); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + eec = ec; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(wec == std::errc::no_space_on_device); + BOOST_TEST(rec == std::errc::io_error); + BOOST_TEST(eec == capy::error::eof); + rf.close(); + ::unlink(path.c_str()); + } + + void testSignalPipeFails() + { + // The signal pipe is process-wide and created once; these + // faults only fire in a fresh process, so run them in a fork. + in_child([&]{ + io_context ioc(Backend); + signal_set ss(ioc); + fault_scope f(sys::pipe, EMFILE); + auto ec = ss.add(SIGUSR2); + return f.fired() && ec == std::errc::io_error; + }); + // 1..3 are F_GETFL, F_SETFL and F_SETFD on the read end. + for(unsigned nth : {1u, 2u, 3u}) + { + in_child([&]{ + io_context ioc(Backend); + signal_set ss(ioc); + int before = open_fds(); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = ss.add(SIGUSR2); + return f.fired() && ec == std::errc::io_error && + open_fds() == before; + }); + } + in_child([&]{ + io_context ioc(Backend); + signal_set ss(ioc); + fault_scope f(sys::sigaction, EINVAL); + auto ec = ss.add(SIGUSR2); + return f.fired() && ec == std::errc::invalid_argument; + }); + in_child([&]{ + io_context ioc(Backend); + signal_set ss(ioc); + if(ss.add(SIGUSR2)) + return false; + fault_scope f(sys::sigaction, EINVAL); + auto ec = ss.remove(SIGUSR2); + return f.fired() && ec == std::errc::invalid_argument; + }); + in_child([&]{ + io_context ioc(Backend); + signal_set ss(ioc); + if(ss.add(SIGUSR2)) + return false; + fault_scope f(sys::sigaction, EINVAL); + auto ec = ss.clear(); + return f.fired() && ec == std::errc::invalid_argument; + }); + } + + void testResolverFails() + { + io_context ioc(Backend); + resolver r(ioc); + std::error_code fec, rec; + auto t = [&]() -> capy::task<> + { + { + fault_scope f(sys::getaddrinfo, EAI_FAIL, 1, any_thread); + auto [ec, results] = co_await r.resolve("localhost", "80"); + std::ignore = results; + fec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::getnameinfo, EAI_FAIL, 1, any_thread); + auto [ec, result] = co_await r.resolve( + endpoint(ipv4_address::loopback(), 80)); + std::ignore = result; + rec = ec; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(fec == std::errc::io_error); + BOOST_TEST(rec == std::errc::io_error); + } + + void run() + { + if(skip_under_valgrind()) + return; + testSocketOpenFails(); + testBindFails(); + testSetOptionFails(); + testGetOptionFails(); + testAssignValidateFails(); + testConnectPairFails(); + testAvailableThrows(); + testHostNameFails(); + testStreamFileOpenFails(); + testStreamFileSyncOps(); + testStreamFileIoFails(); + testRandomAccessFileFails(); + testSignalPipeFails(); + testResolverFails(); + } +}; + +COROSIO_NON_IOCP_BACKEND_TESTS(posix_common_faults, "boost.corosio.fault.posix") + +template +struct reactor_acceptor_option_faults +{ + void testAcceptorOptions() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + { + fault_scope f(sys::setsockopt, ENOPROTOOPT); + expect_system_error( + [&]{ acc.set_option(socket_option::reuse_address(true)); }, + std::errc::no_protocol_option); + BOOST_TEST(f.fired()); + BOOST_TEST(acc.is_open()); + } + { + fault_scope f(sys::getsockopt, ENOPROTOOPT); + expect_system_error( + [&]{ + std::ignore = + acc.get_option(); + }, + std::errc::no_protocol_option); + BOOST_TEST(f.fired()); + BOOST_TEST(acc.is_open()); + } + } + + void run() + { + if(skip_under_valgrind()) + return; + testAcceptorOptions(); + } +}; + +COROSIO_REACTOR_BACKEND_TESTS( + reactor_acceptor_option_faults, "boost.corosio.fault.posix.acceptor_opts") + +} // boost::corosio::test::fault diff --git a/test/unit/fault/reactor_faults.cpp b/test/unit/fault/reactor_faults.cpp new file mode 100644 index 000000000..dd0b408e8 --- /dev/null +++ b/test/unit/fault/reactor_faults.cpp @@ -0,0 +1,17 @@ +// +// 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 +// + +#include "reactor_faults.hpp" + +namespace boost::corosio::test::fault { + +COROSIO_REACTOR_BACKEND_TESTS( + reactor_common_faults, "boost.corosio.fault.reactor"); + +} // boost::corosio::test::fault diff --git a/test/unit/fault/reactor_faults.hpp b/test/unit/fault/reactor_faults.hpp new file mode 100644 index 000000000..39cf27b01 --- /dev/null +++ b/test/unit/fault/reactor_faults.hpp @@ -0,0 +1,702 @@ +// +// 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_TEST_FAULT_REACTOR_FAULTS_HPP +#define BOOST_COROSIO_TEST_FAULT_REACTOR_FAULTS_HPP + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace boost::corosio::test::fault { + +/* Fault tests that hold for every reactor backend. + + The syscalls these arm live in the shared reactor sources + (reactor_op.hpp, reactor_descriptor_state.hpp, + reactor_stream_socket.hpp, reactor_datagram_socket.hpp) or in + traits that spell them identically on epoll and select, so one body + covers both. Backend-specific registration and run-loop faults stay + in the per-backend files. +*/ +template +struct reactor_common_faults +{ +#if BOOST_COROSIO_HAS_SELECT + static constexpr bool is_select = std::is_same_v< + std::remove_cvref_t, select_t>; +#else + static constexpr bool is_select = false; +#endif + +#if BOOST_COROSIO_HAS_KQUEUE + static constexpr bool is_kqueue = std::is_same_v< + std::remove_cvref_t, kqueue_t>; +#else + static constexpr bool is_kqueue = false; +#endif + + // Every backend routes a write through its own write_policy, so the + // symbol to arm differs. kqueue spells write_policy::write_one + // write() and write_policy::write writev(); epoll and select spell + // the same two send(MSG_NOSIGNAL) and sendmsg(), falling back to + // write() where the platform has no MSG_NOSIGNAL. Darwin defines + // it, so select on macOS arms exactly as it does on Linux. +#if defined(MSG_NOSIGNAL) + static constexpr sys spec_write = is_kqueue ? sys::write : sys::send; +#else + static constexpr sys spec_write = sys::write; +#endif + static constexpr sys vec_write = + is_kqueue ? sys::writev : sys::sendmsg; + + static endpoint loopback() + { + return endpoint(ipv4_address::loopback(), 0); + } + + /* Connect a pair whose sender cannot outrun its peer. + + The send buffer has to be shrunk before the handshake: Darwin + auto-tunes an established socket, so a size set afterwards no + longer binds (wait.cpp, make_backpressured_pair). The accepted socket + inherits the listener's, so both ends are shrunk here. The + receive buffers keep their defaults: shrinking those closes the + TCP window, and the parked write then waits on the persist timer + rather than on the reader, which costs seconds on a backend that + has no latched write readiness to short-circuit it. + */ + static std::pair + make_backpressured_pair(io_context& ioc) + { + auto ex = ioc.get_executor(); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + acc.set_option(socket_option::reuse_address(true)); + acc.set_option(socket_option::send_buffer_size(1024)); + // set_option throws rather than reporting, so reaching here + // only proves the call was accepted. Every kernel clamps and + // rescales the request, so read the effective size back: what + // the caller depends on is that it is far below the payload, + // not that it is 1 KiB. + BOOST_TEST( + acc.get_option().value() < + 64 * 1024); + BOOST_TEST(!acc.bind(loopback())); + BOOST_TEST(!acc.listen()); + auto port = acc.local_endpoint().port(); + + tcp_socket s1(ioc), s2(ioc); + BOOST_TEST(!s2.open(tcp::v4())); + s2.set_option(socket_option::send_buffer_size(1024)); + BOOST_TEST( + s2.get_option().value() < + 64 * 1024); + + std::error_code aec, cec; + auto accept_task = [&]() -> capy::task<> + { + auto [ec] = co_await acc.accept(s1); + aec = ec; + }; + auto connect_task = [&]() -> capy::task<> + { + auto [ec] = co_await s2.connect( + endpoint(ipv4_address::loopback(), port)); + cec = ec; + }; + capy::run_async(ex)(accept_task()); + capy::run_async(ex)(connect_task()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!aec); + BOOST_TEST(!cec); + return {std::move(s1), std::move(s2)}; + } + + void testAcceptorFails() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc); + { + fault_scope f(sys::socket, EMFILE); + BOOST_TEST(acc.open() == std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + } + BOOST_TEST(!acc.open()); + { + fault_scope f(sys::bind, EACCES); + BOOST_TEST(acc.bind(loopback()) == std::errc::permission_denied); + BOOST_TEST(f.fired()); + } + BOOST_TEST(!acc.bind(loopback())); + { + fault_scope f(sys::listen, EADDRINUSE); + BOOST_TEST(acc.listen() == std::errc::address_in_use); + BOOST_TEST(f.fired()); + } + // Not latched: the failed listen left the acceptor open and + // unregistered, so a second listen still succeeds. + BOOST_TEST(!acc.listen()); + { + int before = open_fds(); + fault_scope f(sys::socket, EMFILE); + expect_system_error( + [&]{ tcp_acceptor a2(ioc, loopback()); }, + std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testConnectFails() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc, loopback()); + // A faulted EINPROGRESS parks the connect op on a descriptor + // the real connect() never touched. BSD never reports such a + // socket as writable -- sowriteable() requires SS_ISCONNECTED + // for a stream socket -- so neither kqueue nor select would + // ever dispatch the parked op there, while Linux raises + // POLLOUT on it immediately. Park the two deferred cases on + // sockets that are already connected, which are writable + // everywhere; the faulted connect still decides the result, + // because the real syscall never runs. On a connected socket + // register_op may find write_ready already latched and run + // perform_io() inline, so what these blocks prove is that the + // probe and the SO_ERROR read in reactor_connect_op::perform_io + // are reached -- not that a reactor dispatch delivered them. + auto [d1, peer1] = test::make_socket_pair(ioc); + auto [d2, peer2] = test::make_socket_pair(ioc); + std::error_code sync_ec, poll_ec, soerr_ec; + auto body = [&]() -> capy::task<> + { + { + tcp_socket s(ioc); + fault_scope f(sys::connect, ENETUNREACH); + auto [ec] = co_await s.connect(acc.local_endpoint()); + sync_ec = ec; + BOOST_TEST(f.fired()); + } + { + // Loopback connect completes synchronously, so the + // deferred path is only reachable by making connect + // report EINPROGRESS. + fault_scope f1(sys::connect, EINPROGRESS); + fault_scope f2(sys::poll, EIO); + auto [ec] = co_await d1.connect(acc.local_endpoint()); + poll_ec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + { + fault_scope f1(sys::connect, EINPROGRESS); + fault_scope f2(sys::getsockopt, EBADF); + auto [ec] = co_await d2.connect(acc.local_endpoint()); + soerr_ec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + // Named only so the structured binding is complete; what + // matters is the scope, which keeps each peer open across the + // run. A closed peer would reset the connection and make the + // parked socket writable for the wrong reason. + std::ignore = peer1; + std::ignore = peer2; + BOOST_TEST(sync_ec == std::errc::network_unreachable); + BOOST_TEST(poll_ec == std::errc::io_error); + BOOST_TEST(soerr_ec == std::errc::bad_file_descriptor); + } + + void testStreamIoFails() + { + io_context ioc(Backend); + auto [a, b] = test::make_socket_pair(ioc); + char buf[8] = "1234567"; + std::error_code rec, wec, rec_multi, wec_multi, drec; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(spec_write, EPIPE); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(spec_write, EINTR); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf, 7)); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 7u); + } + { + fault_scope f(sys::recv, ECONNRESET); + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::recv, EINTR); + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 7)); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 7u); + } + char x[4] = {}, y[4] = {}; + std::array mb{ + capy::mutable_buffer(x, 4), capy::mutable_buffer(y, 4)}; + std::array cb{ + capy::const_buffer(x, 4), capy::const_buffer(y, 4)}; + { + fault_scope f(vec_write, EPIPE); + auto [ec, n] = co_await a.write_some(cb); + std::ignore = n; + wec_multi = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(vec_write, EINTR); + auto [ec, n] = co_await a.write_some(cb); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 8u); + } + { + auto [ec, n] = co_await a.write_some(cb); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 8u); + } + { + fault_scope f(sys::readv, ECONNRESET); + auto [ec, n] = co_await b.read_some(mb); + std::ignore = n; + rec_multi = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::readv, EINTR); + auto [ec, n] = co_await b.read_some(mb); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 8u); + } + { + auto [ec, n] = co_await b.read_some(mb); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 8u); + } + // Deferred read: the speculative recv reports EAGAIN, so the + // reactor re-runs the op, which always uses readv even for a + // single buffer. + { + fault_scope f1(sys::recv, EAGAIN); + fault_scope f2(sys::readv, EIO); + auto writer = [&]() -> capy::task<> + { + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf, 4)); + std::ignore = n; + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(writer()); + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + drec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(wec == std::errc::broken_pipe); + BOOST_TEST(rec == std::errc::connection_reset); + BOOST_TEST(wec_multi == std::errc::broken_pipe); + BOOST_TEST(rec_multi == std::errc::connection_reset); + BOOST_TEST(drec == std::errc::io_error); + } + + void testDeferredWriteFails() + { + io_context ioc(Backend); + // A faulted EAGAIN cannot park a write durably: the socket + // stays writable, so an edge-triggered backend never fires + // again and the op would never be retried. Push real + // backpressure instead, so draining the peer produces a + // genuine writable event. + auto [a, b] = make_backpressured_pair(ioc); + + // The writer only parks once the peer's receive window is + // closed as well, so the payload has to outrun both buffers: + // the send buffer is pinned small above, but the receive + // buffer keeps its default and Darwin autotunes it into the + // hundreds of kilobytes. 256 KiB sat right at that boundary + // and parked only sometimes. + std::vector payload(4 * 1024 * 1024, 'X'); + std::error_code wec; + // The speculative single-buffer write takes the write_one fast + // path, so the vector form is reachable only from the parked + // op's reactor retry (reactor_write_op::perform_io). + fault_scope f(vec_write, EIO); + auto writer = [&]() -> capy::task<> + { + std::size_t off = 0; + while(off < payload.size()) + { + auto [ec, n] = co_await a.write_some(capy::const_buffer( + payload.data() + off, payload.size() - off)); + if(ec) + { + wec = ec; + break; + } + off += n; + } + a.close(); + }; + auto reader = [&]() -> capy::task<> + { + std::vector sink(4096); + for(;;) + { + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(sink.data(), sink.size())); + if(ec || n == 0) + break; + } + }; + capy::run_async(ioc.get_executor())(writer()); + capy::run_async(ioc.get_executor())(reader()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(wec == std::errc::io_error); + } + + void testShutdownAndWaitFails() + { + io_context ioc(Backend); + // b is unused beyond keeping the peer of a alive. + auto [a, b] = test::make_socket_pair(ioc); + std::ignore = b; + { + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_send) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + std::error_code wec; + auto body = [&]() -> capy::task<> + { + fault_scope f(sys::poll, EIO); + auto [ec] = co_await a.wait(wait_type::write); + wec = ec; + BOOST_TEST(f.fired()); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(wec == std::errc::io_error); + } + + void testErrorEventSoError() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc, loopback()); + std::error_code rec; + auto body = [&]() -> capy::task<> + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + auto [ec] = co_await acc.accept(s); + BOOST_TEST(!ec); + } + if constexpr(is_select) + { + // select() reports an exceptional condition only for + // out-of-band data; a RST shows up as plain readability + // and never reaches the SO_ERROR probe. One OOB byte is + // the portable way to raise the except set. + char oob = '!'; + BOOST_TEST_EQ( + ::send(s.native_handle(), &oob, 1, MSG_OOB), 1); + } + else + { + // SO_LINGER 0 makes close send a RST, which surfaces as + // an error event on the parked reader. + s.set_option(socket_option::linger(true, 0)); + } + // Nothing else on this path reads a socket option, so the + // armed getsockopt is the reactor's SO_ERROR probe. + fault_scope f1(sys::recv, EAGAIN); + fault_scope f2(sys::getsockopt, EBADF); + // The frame only holds a pointer to the closure, so the + // name has to outlive the read below, not just the spawn. + [[maybe_unused]] auto closer = [&]() -> capy::task<> + { + s.close(); + co_return; + }; + if constexpr(!is_select) + { + capy::run_async(ioc.get_executor())(closer()); + } + char buf[4]; + auto [ec, n] = co_await c.read_some(capy::mutable_buffer(buf, 4)); + std::ignore = n; + rec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + if constexpr(is_select) + { + // The OOB byte keeps the except set raised, so the + // socket that raised it has to go before the run loop + // is asked for another pass. + s.close(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(rec == std::errc::bad_file_descriptor); + } + + void testDatagramFails() + { + io_context ioc(Backend); + udp_socket a(ioc), b(ioc); + BOOST_TEST(!a.open(udp::v4())); + BOOST_TEST(!b.open(udp::v4())); + BOOST_TEST(!a.bind(loopback())); + BOOST_TEST(!b.bind(loopback())); + { + fault_scope f(sys::connect, ENETUNREACH); + std::error_code cec; + auto conn = [&]() -> capy::task<> + { + auto [ec] = co_await a.connect(b.local_endpoint()); + cec = ec; + }; + capy::run_async(ioc.get_executor())(conn()); + ioc.run(); + ioc.restart(); + BOOST_TEST(f.fired()); + BOOST_TEST(cec == std::errc::network_unreachable); + } + { + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_both) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + char buf[8] = "1234567"; + std::error_code stec, rfec, sec, rec, dstec, drfec, dsfec, drrec; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::sendmsg, EPERM); + auto [ec, n] = co_await a.send_to( + capy::const_buffer(buf, 7), b.local_endpoint()); + std::ignore = n; + stec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await a.send_to( + capy::const_buffer(buf, 7), b.local_endpoint()); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::recvmsg, EIO); + endpoint from; + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, 7), from); + std::ignore = n; + rfec = ec; + BOOST_TEST(f.fired()); + } + { + endpoint from; + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, 7), from); + std::ignore = n; + BOOST_TEST(!ec); + } + // Deferred send_to. A faulted EAGAIN cannot make a UDP + // socket unwritable, so no later edge would ever arrive to + // retry a parked op on an edge-triggered backend. Use a + // socket registered this instant and hop the run loop once, + // which dispatches the registration's writable event and + // latches desc_state.write_ready; register_op then runs the + // parked op's perform_io() inline instead of waiting for an + // edge. select is level-triggered and re-reports the fd as + // soon as the op opts into the write set, so the hop is + // merely harmless there. + { + udp_socket d(ioc); + BOOST_TEST(!d.open(udp::v4())); + BOOST_TEST(!d.bind(loopback())); + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + fault_scope f1(sys::sendmsg, EAGAIN); + fault_scope f2(sys::sendmsg, EIO, 2); + auto [ec, n] = co_await d.send_to( + capy::const_buffer(buf, 7), b.local_endpoint()); + std::ignore = n; + dsfec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + { + fault_scope f1(sys::recvmsg, EAGAIN); + fault_scope f2(sys::recvmsg, EIO, 2); + auto sender = [&]() -> capy::task<> + { + auto [ec, n] = co_await a.send_to( + capy::const_buffer(buf, 4), b.local_endpoint()); + std::ignore = n; + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(sender()); + endpoint from; + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, 7), from); + std::ignore = n; + drrec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + { + auto [ec] = co_await a.connect(b.local_endpoint()); + BOOST_TEST(!ec); + } + { + fault_scope f(sys::sendmsg, EPERM); + auto [ec, n] = co_await a.send(capy::const_buffer(buf, 7)); + std::ignore = n; + sec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await a.send(capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::recvmsg, EIO); + auto [ec, n] = co_await b.recv(capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await b.recv(capy::mutable_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f1(sys::recvmsg, EAGAIN); + fault_scope f2(sys::recvmsg, EIO, 2); + auto sender = [&]() -> capy::task<> + { + auto [ec, n] = co_await a.send( + capy::const_buffer(buf, 4)); + std::ignore = n; + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(sender()); + auto [ec, n] = co_await b.recv(capy::mutable_buffer(buf, 7)); + std::ignore = n; + drfec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + // Deferred connected send; same latching hop as send_to. + { + udp_socket d(ioc); + BOOST_TEST(!d.open(udp::v4())); + BOOST_TEST(!d.bind(loopback())); + { + auto [ec] = co_await d.connect(b.local_endpoint()); + BOOST_TEST(!ec); + } + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + fault_scope f1(sys::sendmsg, EAGAIN); + fault_scope f2(sys::sendmsg, EIO, 2); + auto [ec, n] = co_await d.send(capy::const_buffer(buf, 7)); + std::ignore = n; + dstec = ec; + BOOST_TEST(f1.fired()); + BOOST_TEST(f2.fired()); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(stec == std::errc::operation_not_permitted); + BOOST_TEST(rfec == std::errc::io_error); + BOOST_TEST(sec == std::errc::operation_not_permitted); + BOOST_TEST(rec == std::errc::io_error); + BOOST_TEST(dsfec == std::errc::io_error); + BOOST_TEST(drrec == std::errc::io_error); + BOOST_TEST(drfec == std::errc::io_error); + BOOST_TEST(dstec == std::errc::io_error); + } + + void run() + { + if(skip_under_valgrind()) + return; + testAcceptorFails(); + testConnectFails(); + testStreamIoFails(); + testDeferredWriteFails(); + testShutdownAndWaitFails(); + testErrorEventSoError(); + testDatagramFails(); + } +}; + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/select_faults.cpp b/test/unit/fault/select_faults.cpp new file mode 100644 index 000000000..797722f09 --- /dev/null +++ b/test/unit/fault/select_faults.cpp @@ -0,0 +1,217 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_SELECT + +namespace boost::corosio::test::fault { + +namespace { + +endpoint loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +} // namespace + +struct select_faults +{ + void testConstructorFails() + { + { + int before = open_fds(); + fault_scope f(sys::pipe, EMFILE); + expect_system_error([]{ io_context ioc(select); }, + std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + // Three fcntl calls configure each end of the interrupt pipe, + // read end first: 1-3 fail the read end, 4-6 the write end. + for(unsigned nth : {1u, 2u, 3u, 4u, 5u, 6u}) + { + int before = open_fds(); + fault_scope f(sys::fcntl, EINVAL, nth); + expect_system_error([]{ io_context ioc(select); }, + std::errc::invalid_argument); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testOpenFcntlFails() + { + io_context ioc(select); + for(unsigned nth : {1u, 2u, 3u}) + { + int before = open_fds(); + tcp_socket s(ioc); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + for(unsigned nth : {1u, 2u, 3u}) + { + int before = open_fds(); + tcp_acceptor acc(ioc); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!acc.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + } + + void testAcceptFails() + { + io_context ioc(select); + tcp_acceptor acc(ioc, loopback()); + std::error_code aec; + int leaked = 0; + auto body = [&]() -> capy::task<> + { + tcp_socket client(ioc), server(ioc); + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + // EINTR is retried inside accept_policy, then the real + // accept succeeds. + fault_scope f(sys::accept, EINTR); + auto [ec] = co_await acc.accept(server); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + } + server.close(); + client.close(); + tcp_socket client2(ioc); + { + auto [ec] = co_await client2.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + { + int before = open_fds(); + fault_scope f(sys::accept, ECONNABORTED); + auto [ec] = co_await acc.accept(server); + aec = ec; + leaked = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!server.is_open()); + } + // The aborted accept did not consume the pending + // connection, so the retry still yields it. + { + auto [ec] = co_await acc.accept(server); + BOOST_TEST(!ec); + } + server.close(); + client2.close(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(aec == std::errc::connection_aborted); + BOOST_TEST_EQ(leaked, 0); + } + + void testAcceptFcntlFails() + { + io_context ioc(select); + tcp_acceptor acc(ioc, loopback()); + std::error_code ecs[3]; + int leaked[3] = {}; + auto body = [&]() -> capy::task<> + { + for(unsigned nth = 1; nth <= 3; ++nth) + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + int before = open_fds(); + // Armed after the connect, so the count starts at the + // three fcntl calls accept_policy makes on the + // accepted descriptor. + fault_scope f(sys::fcntl, EINVAL, nth); + auto [ec] = co_await acc.accept(s); + ecs[nth - 1] = ec; + leaked[nth - 1] = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + c.close(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + for(unsigned i = 0; i < 3; ++i) + { + BOOST_TEST(ecs[i] == std::errc::invalid_argument); + BOOST_TEST_EQ(leaked[i], 0); + } + } + + void testRunLoopFaults() + { + io_context ioc(select); + // EINTR and EBADF are the two codes select() retries; every + // other one leaves the run loop through an exception. Only the + // exceptional branch is driven from a test: arming either + // retried code aborts the process instead of looping, so it is + // left uncovered rather than asserted. + fault_scope f(sys::select, EINVAL); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } + + void run() + { + if(skip_under_valgrind()) + return; + testConstructorFails(); + testOpenFcntlFails(); + testAcceptFails(); + testAcceptFcntlFails(); + testRunLoopFaults(); + } +}; + +TEST_SUITE(select_faults, "boost.corosio.fault.select"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/self_test.cpp b/test/unit/fault/self_test.cpp new file mode 100644 index 000000000..65f70620a --- /dev/null +++ b/test/unit/fault/self_test.cpp @@ -0,0 +1,1343 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "test_suite.hpp" + +#include + +#if !defined(_WIN32) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#endif + +#if defined(__APPLE__) || defined(__FreeBSD__) +#include +#endif + +#if BOOST_COROSIO_HAVE_LIBURING +#include +#endif + +#if defined(__APPLE__) +// Defined in fault_posix.cpp under this asm name, which is the only +// way to name it: the C++ identifier never reaches the linker. +extern "C" int corosio_fault_select_extsn( + int, fd_set*, fd_set*, fd_set*, timeval*) __asm__("_select$DARWIN_EXTSN"); +#endif + +#if defined(__linux__) +// Not in any public header the harness pulls in; only reachable via +// _FORTIFY_SOURCE, which self_test.cpp does not build with. +extern "C" { +ssize_t __read_chk(int, void*, size_t, size_t); +ssize_t __recv_chk(int, void*, size_t, size_t, int); +int __poll_chk(pollfd*, nfds_t, int, size_t); +int __open_2(char const*, int); +int __gethostname_chk(char*, size_t, size_t) noexcept; +} +#endif + +namespace boost::corosio::test::fault { + +// BOOST_TEST_CSTR_EQ expands to a bare `string_view(...)`, which needs +// this in scope; capy's test_suite.hpp does not pull one in itself. +using std::string_view; + +struct self_test +{ + void testFiresOnNth() + { + fault_scope f(sys::socket, EMFILE, 2); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(a >= 0); + BOOST_TEST(!f.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(b, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f.fired()); + int c = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(c >= 0); + ::close(a); + ::close(c); + } + + void testDisarmsOnScopeExit() + { + { + fault_scope f(sys::socket, EMFILE, 5); + } + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(a >= 0); + ::close(a); + } + + // should_fail clears `armed` the instant the fault fires; `fired()` + // must still read true for calls made after that, while the scope + // that fired is still alive. + void testFiredScopeStaysFired() + { + fault_scope f(sys::socket, EMFILE, 1); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(b >= 0); + BOOST_TEST(f.fired()); + ::close(b); + } + + // open_fds() backs every leak assertion in the backend suites, and + // on Darwin it reads a different directory; a build where it + // returns -1 would satisfy nothing. + void testOpenFdsProbeWorks() + { + BOOST_TEST(open_fds() > 0); + } + + void testTransparentWhenUnarmed() + { + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + char const msg[] = "abcdefgh"; + BOOST_TEST_EQ(::write(sv[0], msg, sizeof(msg)), (ssize_t)sizeof(msg)); + char buf[16] = {}; + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), (ssize_t)sizeof(msg)); + BOOST_TEST_CSTR_EQ(buf, msg); + ::close(sv[0]); + ::close(sv[1]); + } + + void testThreadIsolation() + { + fault_scope f(sys::socket, EMFILE); + int other = -2; + std::thread t([&]{ other = ::socket(AF_INET, SOCK_STREAM, 0); }); + t.join(); + BOOST_TEST(other >= 0); + BOOST_TEST(!f.fired()); + ::close(other); + int mine = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(mine, -1); + BOOST_TEST(f.fired()); + } + + // The library's file I/O and name resolution run on a thread pool, + // so a scope for those must be visible outside the test thread. + void testAnyThreadFires() + { + fault_scope f(sys::socket, EMFILE, 1, any_thread); + int other = -2; + int other_errno = 0; + std::thread t([&] + { + other = ::socket(AF_INET, SOCK_STREAM, 0); + other_errno = errno; + }); + t.join(); + BOOST_TEST_EQ(other, -1); + BOOST_TEST_EQ(other_errno, EMFILE); + BOOST_TEST(f.fired()); + } + + // A thread that armed its own slot keeps using it; the global is + // only the fallback. + void testThreadLocalWinsOverAnyThread() + { + fault_scope g(sys::listen, EOPNOTSUPP, 1, any_thread); + fault_scope f(sys::socket, EMFILE); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST(f.fired()); + BOOST_TEST(!g.fired()); + // With the thread-local slot spent, the global takes over here. + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(b >= 0); + BOOST_TEST_EQ(::listen(b, 1), -1); + BOOST_TEST_EQ(errno, EOPNOTSUPP); + BOOST_TEST(g.fired()); + ::close(b); + } + + void testAnyThreadDisarmsOnScopeExit() + { + { + fault_scope f(sys::socket, EMFILE, 1, any_thread); + } + int other = -2; + std::thread t([&]{ other = ::socket(AF_INET, SOCK_STREAM, 0); }); + t.join(); + BOOST_TEST(other >= 0); + ::close(other); + } + + void testAnyThreadShortens() + { + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + BOOST_TEST_EQ(::write(sv[0], "0123456789", 10), 10); + ssize_t n = -2; + { + auto f = fault_scope::returning_any_thread(sys::read, 3); + std::thread t([&] + { + char buf[16]; + n = ::read(sv[1], buf, sizeof(buf)); + }); + t.join(); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(n, 3); + ::close(sv[0]); + ::close(sv[1]); + } + + // Deferred-path tests park an operation with one arm and fail its + // reactor retry with another, so arms must be independent. + void testTwoArmsCoexist() + { + fault_scope f1(sys::socket, EMFILE); + fault_scope f2(sys::listen, EOPNOTSUPP); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f1.fired()); + BOOST_TEST(!f2.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(b >= 0); + BOOST_TEST_EQ(::listen(b, 1), -1); + BOOST_TEST_EQ(errno, EOPNOTSUPP); + BOOST_TEST(f2.fired()); + ::close(b); + } + + // Two arms on the same symbol each count calls on their own, so + // nth selects which occurrence a given arm claims. + void testTwoArmsSameSymbolCountIndependently() + { + fault_scope f1(sys::socket, EMFILE, 1); + fault_scope f2(sys::socket, EACCES, 2); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f1.fired()); + BOOST_TEST(!f2.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(b, -1); + BOOST_TEST_EQ(errno, EACCES); + BOOST_TEST(f2.fired()); + int c = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(c >= 0); + ::close(c); + } + + // Two arms can reach their nth on the same call, but only one + // fault is delivered; the loser must stay armed and keep counting + // rather than be silently marked fired. + void testSameNthOnlyOneArmFires() + { + fault_scope f1(sys::socket, EMFILE); + fault_scope f2(sys::socket, EACCES); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(a, -1); + BOOST_TEST_EQ(errno, EMFILE); + BOOST_TEST(f1.fired()); + BOOST_TEST(!f2.fired()); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(b, -1); + BOOST_TEST_EQ(errno, EACCES); + BOOST_TEST(f2.fired()); + int c = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(c >= 0); + ::close(c); + } + + void testOnlyMatchingSymbolFires() + { + fault_scope f(sys::listen, EOPNOTSUPP); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(a >= 0); + BOOST_TEST(!f.fired()); + BOOST_TEST_EQ(::listen(a, 1), -1); + BOOST_TEST_EQ(errno, EOPNOTSUPP); + BOOST_TEST(f.fired()); + ::close(a); + } + + void testEveryCensusSymbolFails() + { + // One representative call per shadow, each expected to fail. + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(fd >= 0); + auto expect = [&](sys s, auto&& call) + { + fault_scope f(s, EPERM); + auto r = call(); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ((long)r, -1L); + BOOST_TEST_EQ(errno, EPERM); + }; + sockaddr_in sa{}; + sa.sin_family = AF_INET; + socklen_t len = sizeof(sa); + int one = 1; + char buf[4]; + iovec iov{buf, sizeof(buf)}; + msghdr mh{}; + mh.msg_iov = &iov; + mh.msg_iovlen = 1; + pollfd pfd{fd, POLLIN, 0}; + int pf[2]; + struct stat st; + struct sigaction sa_old; + addrinfo* ai = nullptr; + char host[64]; + timeval tv{0, 0}; + fd_set fds; + FD_ZERO(&fds); + int sv[2]; + + expect(sys::socketpair, [&]{ return ::socketpair(AF_UNIX, SOCK_STREAM, 0, sv); }); + expect(sys::bind, [&]{ return ::bind(fd, (sockaddr*)&sa, sizeof(sa)); }); + expect(sys::listen, [&]{ return ::listen(fd, 1); }); + expect(sys::accept, [&]{ return ::accept(fd, nullptr, nullptr); }); + expect(sys::connect, [&]{ return ::connect(fd, (sockaddr*)&sa, sizeof(sa)); }); + expect(sys::getsockname, [&]{ return ::getsockname(fd, (sockaddr*)&sa, &len); }); + expect(sys::getpeername, [&]{ return ::getpeername(fd, (sockaddr*)&sa, &len); }); + expect(sys::getsockopt, [&]{ len = sizeof(one); return ::getsockopt(fd, SOL_SOCKET, SO_TYPE, &one, &len); }); + expect(sys::setsockopt, [&]{ return ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); }); + expect(sys::shutdown, [&]{ return ::shutdown(fd, SHUT_RD); }); + expect(sys::read, [&]{ return ::read(fd, buf, sizeof(buf)); }); + expect(sys::write, [&]{ return ::write(fd, buf, sizeof(buf)); }); + expect(sys::writev, [&]{ return ::writev(fd, &iov, 1); }); + expect(sys::readv, [&]{ return ::readv(fd, &iov, 1); }); + expect(sys::preadv, [&]{ return ::preadv(fd, &iov, 1, 0); }); + expect(sys::pwritev, [&]{ return ::pwritev(fd, &iov, 1, 0); }); + expect(sys::recv, [&]{ return ::recv(fd, buf, sizeof(buf), 0); }); + expect(sys::send, [&]{ return ::send(fd, buf, sizeof(buf), 0); }); + expect(sys::recvmsg, [&]{ return ::recvmsg(fd, &mh, 0); }); + expect(sys::sendmsg, [&]{ return ::sendmsg(fd, &mh, 0); }); + expect(sys::poll, [&]{ return ::poll(&pfd, 1, 0); }); + expect(sys::pipe, [&]{ return ::pipe(pf); }); + expect(sys::fcntl, [&]{ return ::fcntl(fd, F_GETFL); }); + expect(sys::ioctl, [&]{ return ::ioctl(fd, FIONREAD, &one); }); + expect(sys::open, [&]{ return ::open("/dev/null", O_RDONLY); }); + expect(sys::fstat, [&]{ return ::fstat(fd, &st); }); + expect(sys::lseek, [&]{ return (long)::lseek(fd, 0, SEEK_SET); }); + expect(sys::ftruncate, [&]{ return ::ftruncate(fd, 0); }); + expect(sys::fsync, [&]{ return ::fsync(fd); }); + expect(sys::unlink, [&]{ return ::unlink("/nonexistent/x"); }); + expect(sys::sigaction, [&]{ return ::sigaction(SIGUSR1, nullptr, &sa_old); }); + expect(sys::gethostname, [&]{ return ::gethostname(host, sizeof(host)); }); + expect(sys::select, [&]{ return ::select(1, &fds, nullptr, nullptr, &tv); }); +#if defined(__linux__) || defined(__FreeBSD__) + expect(sys::fdatasync, [&]{ return ::fdatasync(fd); }); +#endif +#if defined(__linux__) + expect(sys::accept4, [&]{ return ::accept4(fd, nullptr, nullptr, 0); }); + expect(sys::epoll_create1, [&]{ return ::epoll_create1(0); }); + expect(sys::epoll_ctl, [&]{ return ::epoll_ctl(fd, EPOLL_CTL_DEL, fd, nullptr); }); + expect(sys::epoll_wait, [&]{ epoll_event ev; return ::epoll_wait(fd, &ev, 1, 0); }); + expect(sys::eventfd, [&]{ return ::eventfd(0, 0); }); + expect(sys::timerfd_create, [&]{ return ::timerfd_create(CLOCK_MONOTONIC, 0); }); + expect(sys::timerfd_settime, [&]{ itimerspec its{}; return ::timerfd_settime(fd, 0, &its, nullptr); }); +#endif +#if defined(__APPLE__) || defined(__FreeBSD__) + expect(sys::kqueue, [&]{ return ::kqueue(); }); + // An invalid kq would fail anyway; the arm must be what fails it. + expect(sys::kevent, [&] + { + struct kevent ch{}; + return ::kevent(-1, &ch, 1, nullptr, 0, nullptr); + }); +#endif + expect(sys::close, [&]{ return ::close(fd); }); + // getaddrinfo / getnameinfo return the error, not -1 +#if defined(__linux__) || defined(__FreeBSD__) + { + fault_scope f(sys::posix_fadvise, EPERM); + BOOST_TEST_EQ(::posix_fadvise(fd, 0, 0, POSIX_FADV_NORMAL), EPERM); + BOOST_TEST(f.fired()); + } +#endif + { + fault_scope f(sys::getaddrinfo, EAI_FAIL); + BOOST_TEST_EQ(::getaddrinfo("localhost", nullptr, nullptr, &ai), EAI_FAIL); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::getnameinfo, EAI_FAIL); + BOOST_TEST_EQ(::getnameinfo((sockaddr*)&sa, sizeof(sa), host, sizeof(host), nullptr, 0, 0), EAI_FAIL); + BOOST_TEST(f.fired()); + } + // freeaddrinfo has no failure to inject; the arm only proves + // the shadow saw the release, and the list is still freed. + { + ai = nullptr; + if(::getaddrinfo("localhost", nullptr, nullptr, &ai) == 0 && ai) + { + fault_scope f(sys::freeaddrinfo, EPERM); + ::freeaddrinfo(ai); + BOOST_TEST(f.fired()); + } + } + ::close(fd); + } + + void testReturningTruncatesAndForwards() + { + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + char const msg[] = "0123456789"; + { + auto f = fault_scope::returning(sys::write, 4); + BOOST_TEST_EQ(::write(sv[0], msg, 10), 4); + BOOST_TEST(f.fired()); + } + char buf[16] = {}; + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), 4); + BOOST_TEST_EQ(std::string_view(buf, 4), "0123"); + + BOOST_TEST_EQ(::write(sv[0], msg, 10), 10); + { + auto f = fault_scope::returning(sys::read, 3); + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), 3); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(std::string_view(buf, 3), "012"); + } + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), 7); + + // iovec truncation keeps the prefix of the scatter list + BOOST_TEST_EQ(::write(sv[0], msg, 10), 10); + char a[4] = {}, b[4] = {}; + iovec iov[2] = {{a, 4}, {b, 4}}; + { + auto f = fault_scope::returning(sys::readv, 6); + BOOST_TEST_EQ(::readv(sv[1], iov, 2), 6); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(std::string_view(a, 4), "0123"); + BOOST_TEST_EQ(std::string_view(b, 2), "45"); + } + BOOST_TEST_EQ(::read(sv[1], buf, sizeof(buf)), 4); + + // zero on the read side is EOF without touching the socket + BOOST_TEST_EQ(::write(sv[0], msg, 10), 10); + { + auto f = fault_scope::returning(sys::recv, 0); + BOOST_TEST_EQ(::recv(sv[1], buf, sizeof(buf), 0), 0); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(::recv(sv[1], buf, sizeof(buf), 0), 10); + ::close(sv[0]); + ::close(sv[1]); + } + +#if defined(__APPLE__) + // spells the call select$DARWIN_EXTSN under + // _DARWIN_C_SOURCE and plain select otherwise. This translation + // unit gets the plain spelling, so the suffixed shadow is reached + // by its asm name to prove both land on the same arm. + void testDarwinSelectAliasReachesHook() + { + fd_set fds; + FD_ZERO(&fds); + timeval tv{0, 0}; + { + fault_scope f(sys::select, EIO); + BOOST_TEST_EQ(::select(1, &fds, nullptr, nullptr, &tv), -1); + BOOST_TEST_EQ(errno, EIO); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::select, EIO); + BOOST_TEST_EQ( + ::corosio_fault_select_extsn(1, &fds, nullptr, nullptr, &tv), + -1); + BOOST_TEST_EQ(errno, EIO); + BOOST_TEST(f.fired()); + } + // Unarmed, the alias still forwards to the real call. + FD_ZERO(&fds); + BOOST_TEST_EQ( + ::corosio_fault_select_extsn(0, &fds, nullptr, nullptr, &tv), 0); + } +#endif + +#if defined(__linux__) + void testChkAliasesReachHook() + { + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + char buf[8]; + { + fault_scope f(sys::read, EIO); + BOOST_TEST_EQ(::__read_chk(sv[1], buf, sizeof(buf), sizeof(buf)), -1); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::recv, EIO); + BOOST_TEST_EQ(::__recv_chk(sv[1], buf, sizeof(buf), sizeof(buf), 0), -1); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::poll, EIO); + pollfd p{sv[1], POLLIN, 0}; + BOOST_TEST_EQ(::__poll_chk(&p, 1, 0, sizeof(p)), -1); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::open, EIO); + BOOST_TEST_EQ(::__open_2("/dev/null", O_RDONLY), -1); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::gethostname, EIO); + char host[64]; + BOOST_TEST_EQ(::__gethostname_chk(host, sizeof(host), sizeof(host)), -1); + BOOST_TEST(f.fired()); + } + ::close(sv[0]); + ::close(sv[1]); + } +#endif + +#if BOOST_COROSIO_HAVE_LIBURING + void testUringSubmitFails() + { + io_uring ring; + io_uring_params p{}; + BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); + { + fault_scope f(sys::io_uring_submit, EBADF); + BOOST_TEST_EQ(io_uring_submit(&ring), -EBADF); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(io_uring_submit(&ring), 0); + io_uring_queue_exit(&ring); + } + + void testUringSqeFull() + { + fault_scope f(sys::uring_sqe_full, 0); + io_uring ring; + io_uring_params p{}; + BOOST_TEST_EQ(io_uring_queue_init_params(64, &ring, &p), 0); + // liburing may round the clamped entry count up to its own + // minimum instead of honoring 1 exactly. + BOOST_TEST(p.sq_entries <= 2); + BOOST_TEST(io_uring_get_sqe(&ring) != nullptr); + BOOST_TEST(io_uring_get_sqe(&ring) == nullptr); + BOOST_TEST_EQ(io_uring_submit(&ring), 0); + BOOST_TEST(f.fired()); + BOOST_TEST(io_uring_get_sqe(&ring) == nullptr); + io_uring_queue_exit(&ring); + } + + void testCqeRewrite() + { + io_uring ring; + io_uring_params p{}; + BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + BOOST_TEST_EQ(::write(sv[0], "xyz", 3), 3); + char buf[8]; + cqe_fault_scope c(sv[1], IORING_OP_RECV, -ECONNRESET); + auto* sqe = io_uring_get_sqe(&ring); + io_uring_prep_recv(sqe, sv[1], buf, sizeof(buf), 0); + io_uring_sqe_set_data64(sqe, 42); + BOOST_TEST_EQ(io_uring_submit(&ring), 1); + io_uring_cqe* cqe = nullptr; + BOOST_TEST_EQ(io_uring_wait_cqe_timeout(&ring, &cqe, nullptr), 0); + BOOST_TEST(c.fired()); + BOOST_TEST_EQ(cqe->user_data, 42u); + BOOST_TEST_EQ(cqe->res, -ECONNRESET); + io_uring_cqe_seen(&ring, cqe); + ::close(sv[0]); + ::close(sv[1]); + io_uring_queue_exit(&ring); + } +#endif + + void run() + { + if(skip_under_valgrind()) + return; + testFiresOnNth(); + testDisarmsOnScopeExit(); + testFiredScopeStaysFired(); + testOpenFdsProbeWorks(); + testTransparentWhenUnarmed(); + testThreadIsolation(); + testAnyThreadFires(); + testThreadLocalWinsOverAnyThread(); + testAnyThreadDisarmsOnScopeExit(); + testAnyThreadShortens(); + testTwoArmsCoexist(); + testTwoArmsSameSymbolCountIndependently(); + testSameNthOnlyOneArmFires(); + testOnlyMatchingSymbolFires(); + testEveryCensusSymbolFails(); + testReturningTruncatesAndForwards(); +#if defined(__APPLE__) + testDarwinSelectAliasReachesHook(); +#endif +#if defined(__linux__) + testChkAliasesReachHook(); +#endif +#if BOOST_COROSIO_HAVE_LIBURING + testUringSubmitFails(); + testUringSqeFull(); + testCqeRewrite(); +#endif + } +}; + +TEST_SUITE(self_test, "boost.corosio.fault.self"); + +} // boost::corosio::test::fault + +#else + +#include +#include +#include + +// MinGW's stops short of this one; the library declares it +// for itself the same way. +#if defined(__MINGW32__) || defined(__MINGW64__) +extern "C" INT WSAAPI GetAddrInfoExCancel(LPHANDLE lpHandle); +#endif + +#include +#include +#include +#include + +namespace boost::corosio::test::fault { + +// BOOST_TEST_CSTR_EQ expands to a bare `string_view(...)`, which needs +// this in scope; capy's test_suite.hpp does not pull one in itself. +using std::string_view; + +namespace { + +// One arbitrary code, armed everywhere: what matters is that the value +// the arm carries is the value the caller reads back, not which code +// it is. +int constexpr test_err = ERROR_NOT_SUPPORTED; + +// Winsock and the kernel share the per-thread error slot, so one read +// covers both families. +bool err_is(int expected) noexcept +{ + return ::GetLastError() == static_cast(expected); +} + +// A connected loopback pair. The shortening hooks clamp a real call, +// so the bytes have to move through a real socket; Windows has no +// socketpair and nothing here builds an io_context to borrow +// connect_pair from. +bool make_loopback_pair(SOCKET& a, SOCKET& b) +{ + a = b = INVALID_SOCKET; + SOCKET acc = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if(acc == INVALID_SOCKET) + return false; + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + int len = static_cast(sizeof(sa)); + bool ok = ::bind(acc, reinterpret_cast(&sa), len) == 0 && + ::listen(acc, 1) == 0 && + ::getsockname(acc, reinterpret_cast(&sa), &len) == 0; + if(ok) + { + a = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + ok = a != INVALID_SOCKET && + ::connect(a, reinterpret_cast(&sa), + static_cast(sizeof(sa))) == 0; + } + if(ok) + { + b = ::accept(acc, nullptr, nullptr); + ok = b != INVALID_SOCKET; + } + std::ignore = ::closesocket(acc); + return ok; +} + +// A stream may deliver a send in pieces. The assertions below are +// about what the hook did to the call it clamped, so the other side +// drains a count it already knows. +int recv_exactly(SOCKET s, char* p, int n) +{ + int got = 0; + while(got < n) + { + int const r = ::recv(s, p + got, n - got, 0); + if(r <= 0) + return got; + got += r; + } + return got; +} + +// Every entry point this file arms is also called from this +// translation unit, so the program imports all of them: a name with no +// thunk to patch here is census drift rather than a toolchain +// difference, and fails rather than skips. +bool require_hook(sys s, char const* name) +{ + if(hook_is_live(s)) + return true; + std::fprintf(stderr, "fault harness: %s is not hooked here\n", name); + BOOST_TEST(false); + return false; +} + +} // namespace + +struct self_test +{ + // Winsock has to be up before any socket call in this program; + // nothing here constructs an io_context to do it. + struct winsock_guard + { + winsock_guard() + { + WSADATA data; + BOOST_TEST_EQ(::WSAStartup(MAKEWORD(2, 2), &data), 0); + } + ~winsock_guard() { std::ignore = ::WSACleanup(); } + }; + + void testFiresOnNth() + { + fault_scope f(sys::socket, WSAEMFILE, 2); + SOCKET a = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(a != INVALID_SOCKET); + BOOST_TEST(!f.fired()); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(b == INVALID_SOCKET); + BOOST_TEST(err_is(WSAEMFILE)); + BOOST_TEST(f.fired()); + SOCKET c = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(c != INVALID_SOCKET); + std::ignore = ::closesocket(a); + std::ignore = ::closesocket(c); + } + + void testDisarmsOnScopeExit() + { + { + fault_scope f(sys::socket, WSAEMFILE, 5); + } + SOCKET a = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(a != INVALID_SOCKET); + std::ignore = ::closesocket(a); + } + + void testFiredScopeStaysFired() + { + fault_scope f(sys::socket, WSAEMFILE); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST(f.fired()); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(b != INVALID_SOCKET); + BOOST_TEST(f.fired()); + std::ignore = ::closesocket(b); + } + + void testOpenFdsProbeWorks() + { + BOOST_TEST(open_fds() > 0); + } + + void testTransparentWhenUnarmed() + { + SOCKET s = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(s != INVALID_SOCKET); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::bind(s, reinterpret_cast(&sa), + static_cast(sizeof(sa))), 0); + BOOST_TEST_EQ(::listen(s, 1), 0); + std::ignore = ::closesocket(s); + } + + void testThreadIsolation() + { + fault_scope f(sys::socket, WSAEMFILE); + SOCKET other = INVALID_SOCKET; + std::thread t([&] + { + other = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + }); + t.join(); + BOOST_TEST(other != INVALID_SOCKET); + BOOST_TEST(!f.fired()); + std::ignore = ::closesocket(other); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST(f.fired()); + } + + // The library resolves names and moves file bytes on a thread + // pool, where the test thread's arms are never consulted. + void testAnyThreadFires() + { + fault_scope f(sys::socket, WSAEMFILE, 1, any_thread); + SOCKET other = INVALID_SOCKET; + DWORD other_err = 0; + std::thread t([&] + { + other = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + other_err = ::GetLastError(); + }); + t.join(); + BOOST_TEST(other == INVALID_SOCKET); + BOOST_TEST_EQ(other_err, static_cast(WSAEMFILE)); + BOOST_TEST(f.fired()); + } + + void testThreadLocalWinsOverAnyThread() + { + fault_scope g(sys::listen, WSAEOPNOTSUPP, 1, any_thread); + fault_scope f(sys::socket, WSAEMFILE); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST(f.fired()); + BOOST_TEST(!g.fired()); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(b != INVALID_SOCKET); + BOOST_TEST_EQ(::listen(b, 1), SOCKET_ERROR); + BOOST_TEST(err_is(WSAEOPNOTSUPP)); + BOOST_TEST(g.fired()); + std::ignore = ::closesocket(b); + } + + void testTwoArmsCoexist() + { + fault_scope f1(sys::socket, WSAEMFILE); + fault_scope f2(sys::listen, WSAEOPNOTSUPP); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST(f1.fired()); + BOOST_TEST(!f2.fired()); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(b != INVALID_SOCKET); + BOOST_TEST_EQ(::listen(b, 1), SOCKET_ERROR); + BOOST_TEST(f2.fired()); + std::ignore = ::closesocket(b); + } + + // One call per hook, each expected to report its documented + // failure with the armed code. A hook the toolchain gave no import + // to patch is named and skipped rather than silently passing. + void testEveryCensusSymbolFails() + { + SOCKET fd = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(fd != INVALID_SOCKET); + HANDLE port = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, + 0, 1); + BOOST_TEST(port != nullptr); + + // This translation unit references every hooked entry point, so + // the program imports every one of them: a hook with no thunk + // to patch here is census drift, not a toolchain difference. + auto expect = [&](sys s, char const* name, auto&& call) + { + if(!require_hook(s, name)) + return; + fault_scope f(s, test_err); + bool const failed = call(); + // Name the symbol: fifty anonymous BOOST_TEST lines say + // nothing about which hook came apart. + if(!failed || !f.fired()) + std::fprintf(stderr, + "fault harness: census failed for %s\n", name); + BOOST_TEST(failed); + BOOST_TEST(f.fired()); + }; + + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + int const salen = static_cast(sizeof(sa)); + int len = salen; + int one = 1; + char buf[8] = {}; + WSABUF wbuf{}; + wbuf.len = static_cast(sizeof(buf)); + wbuf.buf = buf; + DWORD bytes = 0; + DWORD flags = 0; + u_long mode = 1; + WSAPOLLFD pfd{}; + pfd.fd = fd; + pfd.events = static_cast(POLLRDNORM); + LARGE_INTEGER big{}; + wchar_t wide[64] = {}; + char narrow[64] = {}; + DWORD widelen = 64; + ULONG_PTR key = 0; + LPOVERLAPPED got = nullptr; + OVERLAPPED ov{}; + HANDLE cancel = nullptr; + + expect(sys::socket, "socket", [&] + { return ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET && err_is(test_err); }); + expect(sys::WSASocketW, "WSASocketW", [&] + { return ::WSASocketW(AF_INET, SOCK_STREAM, IPPROTO_TCP, nullptr, + 0, WSA_FLAG_OVERLAPPED) == INVALID_SOCKET && + err_is(test_err); }); + expect(sys::bind, "bind", [&] + { return ::bind(fd, reinterpret_cast(&sa), salen) + == SOCKET_ERROR && err_is(test_err); }); + expect(sys::listen, "listen", [&] + { return ::listen(fd, 1) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::accept, "accept", [&] + { return ::accept(fd, nullptr, nullptr) == INVALID_SOCKET && + err_is(test_err); }); + expect(sys::connect, "connect", [&] + { return ::connect(fd, reinterpret_cast(&sa), + salen) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::shutdown, "shutdown", [&] + { return ::shutdown(fd, SD_RECEIVE) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::ioctlsocket, "ioctlsocket", [&] + { return ::ioctlsocket(fd, FIONBIO, &mode) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::getsockname, "getsockname", [&] + { return ::getsockname(fd, reinterpret_cast(&sa), &len) + == SOCKET_ERROR && err_is(test_err); }); + expect(sys::getpeername, "getpeername", [&] + { return ::getpeername(fd, reinterpret_cast(&sa), &len) + == SOCKET_ERROR && err_is(test_err); }); + expect(sys::getsockopt, "getsockopt", [&] + { int optlen = sizeof(one); + return ::getsockopt(fd, SOL_SOCKET, SO_TYPE, + reinterpret_cast(&one), &optlen) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::setsockopt, "setsockopt", [&] + { return ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, + reinterpret_cast(&one), sizeof(one)) == + SOCKET_ERROR && err_is(test_err); }); + expect(sys::send, "send", [&] + { return ::send(fd, buf, sizeof(buf), 0) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::recv, "recv", [&] + { return ::recv(fd, buf, sizeof(buf), 0) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::WSAConnect, "WSAConnect", [&] + { return ::WSAConnect(fd, reinterpret_cast(&sa), + salen, nullptr, nullptr, nullptr, nullptr) == + SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSARecv, "WSARecv", [&] + { return ::WSARecv(fd, &wbuf, 1, &bytes, &flags, nullptr, + nullptr) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSASend, "WSASend", [&] + { return ::WSASend(fd, &wbuf, 1, &bytes, 0, nullptr, nullptr) == + SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSARecvFrom, "WSARecvFrom", [&] + { return ::WSARecvFrom(fd, &wbuf, 1, &bytes, &flags, nullptr, + nullptr, nullptr, nullptr) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::WSASendTo, "WSASendTo", [&] + { return ::WSASendTo(fd, &wbuf, 1, &bytes, 0, + reinterpret_cast(&sa), salen, nullptr, + nullptr) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSAPoll, "WSAPoll", [&] + { return ::WSAPoll(&pfd, 1, 0) == SOCKET_ERROR && + err_is(test_err); }); + expect(sys::WSAIoctl, "WSAIoctl", [&] + { GUID g = WSAID_ACCEPTEX; void* p = nullptr; + return ::WSAIoctl(fd, SIO_GET_EXTENSION_FUNCTION_POINTER, &g, + sizeof(g), &p, sizeof(p), &bytes, nullptr, nullptr) == + SOCKET_ERROR && err_is(test_err); }); + expect(sys::closesocket, "closesocket", [&] + { return ::closesocket(fd) == SOCKET_ERROR && err_is(test_err); }); + expect(sys::WSAStartup, "WSAStartup", [&] + { WSADATA d; return ::WSAStartup(MAKEWORD(2, 2), &d) == + test_err; }); + expect(sys::WSACleanup, "WSACleanup", [&] + { return ::WSACleanup() == SOCKET_ERROR && err_is(test_err); }); + expect(sys::GetAddrInfoExW, "GetAddrInfoExW", [&] + { PADDRINFOEXW res = nullptr; + return ::GetAddrInfoExW(L"localhost", nullptr, NS_DNS, nullptr, + nullptr, &res, nullptr, nullptr, nullptr, nullptr) == + test_err; }); + expect(sys::GetAddrInfoExCancel, "GetAddrInfoExCancel", [&] + { return ::GetAddrInfoExCancel(&cancel) == test_err; }); + expect(sys::GetNameInfoW, "GetNameInfoW", [&] + { return ::GetNameInfoW(reinterpret_cast(&sa), + salen, wide, 64, nullptr, 0, 0) == test_err; }); + expect(sys::CreateIoCompletionPort, "CreateIoCompletionPort", [&] + { return ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, + 0, 1) == nullptr && err_is(test_err); }); + expect(sys::PostQueuedCompletionStatus, "PostQueuedCompletionStatus", + [&] { return ::PostQueuedCompletionStatus(port, 0, 0, &ov) == + FALSE && err_is(test_err); }); + expect(sys::GetQueuedCompletionStatus, "GetQueuedCompletionStatus", + [&] + { got = &ov; + return ::GetQueuedCompletionStatus(port, &bytes, &key, &got, 0) + == FALSE && got == nullptr && err_is(test_err); }); + expect(sys::CancelIoEx, "CancelIoEx", [&] + { return ::CancelIoEx(port, nullptr) == FALSE && + err_is(test_err); }); + expect(sys::CreateFileW, "CreateFileW", [&] + { return ::CreateFileW(L"nul", GENERIC_READ, 0, nullptr, + OPEN_EXISTING, 0, nullptr) == INVALID_HANDLE_VALUE && + err_is(test_err); }); + expect(sys::ReadFile, "ReadFile", [&] + { return ::ReadFile(port, buf, sizeof(buf), &bytes, nullptr) == + FALSE && err_is(test_err); }); + expect(sys::WriteFile, "WriteFile", [&] + { return ::WriteFile(port, buf, sizeof(buf), &bytes, nullptr) == + FALSE && err_is(test_err); }); + expect(sys::SetFilePointerEx, "SetFilePointerEx", [&] + { return ::SetFilePointerEx(port, big, nullptr, FILE_BEGIN) == + FALSE && err_is(test_err); }); + expect(sys::GetFileSizeEx, "GetFileSizeEx", [&] + { return ::GetFileSizeEx(port, &big) == FALSE && + err_is(test_err); }); + expect(sys::SetEndOfFile, "SetEndOfFile", [&] + { return ::SetEndOfFile(port) == FALSE && err_is(test_err); }); + expect(sys::FlushFileBuffers, "FlushFileBuffers", [&] + { return ::FlushFileBuffers(port) == FALSE && err_is(test_err); }); + expect(sys::DeleteFileA, "DeleteFileA", [&] + { return ::DeleteFileA("nonexistent-corosio-fault") == FALSE && + err_is(test_err); }); + expect(sys::CreateWaitableTimerW, "CreateWaitableTimerW", [&] + { return ::CreateWaitableTimerW(nullptr, TRUE, nullptr) == + nullptr && err_is(test_err); }); + expect(sys::SetWaitableTimer, "SetWaitableTimer", [&] + { return ::SetWaitableTimer(port, &big, 0, nullptr, nullptr, + FALSE) == FALSE && err_is(test_err); }); + expect(sys::WaitForSingleObject, "WaitForSingleObject", [&] + { return ::WaitForSingleObject(port, 0) == WAIT_FAILED && + err_is(test_err); }); + expect(sys::GetComputerNameExW, "GetComputerNameExW", [&] + { return ::GetComputerNameExW(ComputerNameDnsHostname, wide, + &widelen) == FALSE && err_is(test_err); }); + expect(sys::GetModuleHandleA, "GetModuleHandleA", [&] + { return ::GetModuleHandleA("kernel32") == nullptr && + err_is(test_err); }); + expect(sys::GetModuleHandleW, "GetModuleHandleW", [&] + { return ::GetModuleHandleW(L"kernel32") == nullptr && + err_is(test_err); }); + expect(sys::GetProcAddress, "GetProcAddress", [&] + { return ::GetProcAddress(::GetModuleHandleW(L"kernel32"), + "CloseHandle") == nullptr && err_is(test_err); }); + expect(sys::MultiByteToWideChar, "MultiByteToWideChar", [&] + { return ::MultiByteToWideChar(CP_UTF8, 0, "x", 1, wide, 64) == 0 + && err_is(test_err); }); + expect(sys::WideCharToMultiByte, "WideCharToMultiByte", [&] + { return ::WideCharToMultiByte(CP_UTF8, 0, L"x", 1, narrow, 64, + nullptr, nullptr) == 0 && err_is(test_err); }); + expect(sys::signal, "signal", [&] + { return ::signal(SIGINT, SIG_DFL) == SIG_ERR && + err_is(test_err); }); + // Last, since an armed CloseHandle leaves the port open and the + // real close has to follow. + expect(sys::CloseHandle, "CloseHandle", [&] + { return ::CloseHandle(port) == FALSE && err_is(test_err); }); + + std::ignore = ::CloseHandle(port); + std::ignore = ::closesocket(fd); + } + + // The shortening hooks are the one family that forwards a modified + // call rather than refusing it, and the non-zero clamps are + // otherwise unreached on this platform. + void testReturningTruncatesAndForwards() + { + SOCKET a = INVALID_SOCKET, b = INVALID_SOCKET; + if(!make_loopback_pair(a, b)) + { + BOOST_TEST(false); + return; + } + char const msg[] = "0123456789"; + char buf[16] = {}; + { + auto f = fault_scope::returning(sys::send, 4); + BOOST_TEST_EQ(::send(a, msg, 10, 0), 4); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(recv_exactly(b, buf, 4), 4); + BOOST_TEST_EQ(std::string_view(buf, 4), "0123"); + + // A two-entry WSABUF array walks truncate_wsabuf's prefix: the + // first buffer fills and the second takes the remainder. + BOOST_TEST_EQ(::send(a, msg, 10, 0), 10); + char p1[4] = {}, p2[4] = {}; + WSABUF wb[2]; + wb[0].buf = p1; + wb[0].len = 4; + wb[1].buf = p2; + wb[1].len = 4; + { + auto f = fault_scope::returning(sys::WSARecv, 6); + DWORD bytes = 0; + DWORD flags = 0; + BOOST_TEST_EQ(::WSARecv(b, wb, 2, &bytes, &flags, nullptr, + nullptr), 0); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(bytes, static_cast(6)); + BOOST_TEST_EQ(std::string_view(p1, 4), "0123"); + BOOST_TEST_EQ(std::string_view(p2, 2), "45"); + } + BOOST_TEST_EQ(recv_exactly(b, buf, 4), 4); + + // Zero on the read side is EOF without touching the socket. + BOOST_TEST_EQ(::send(a, msg, 10, 0), 10); + { + auto f = fault_scope::returning(sys::recv, 0); + BOOST_TEST_EQ(::recv(b, buf, sizeof(buf), 0), 0); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(recv_exactly(b, buf, 10), 10); + std::ignore = ::closesocket(a); + std::ignore = ::closesocket(b); + } + + // FreeAddrInfoExW has no failure mode, so its arm is a probe that + // the release ran rather than a fault: the hook forwards either + // way, since swallowing the call would leak the list. + void testFreeAddrInfoRunsUnderArm() + { + if(!require_hook(sys::FreeAddrInfoExW, "FreeAddrInfoExW")) + return; + PADDRINFOEXW res = nullptr; + if(::GetAddrInfoExW(L"localhost", nullptr, NS_DNS, nullptr, nullptr, + &res, nullptr, nullptr, nullptr, nullptr) != 0 || !res) + return; + fault_scope f(sys::FreeAddrInfoExW, test_err); + ::FreeAddrInfoExW(res); + BOOST_TEST(f.fired()); + } + + // The one path to the error branches of the completion handlers: + // an operation the kernel completed, reported as failed. + void testCompletionFaultRewrites() + { + if(!require_hook(sys::GetQueuedCompletionStatus, + "GetQueuedCompletionStatus")) + return; + HANDLE port = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, + 0, 1); + BOOST_TEST(port != nullptr); + OVERLAPPED ov{}; + BOOST_TEST(::PostQueuedCompletionStatus(port, 7, 99, &ov) != FALSE); + { + completion_fault_scope c(ERROR_NETNAME_DELETED); + DWORD bytes = 0; + ULONG_PTR key = 0; + LPOVERLAPPED got = nullptr; + ::SetLastError(0); + BOOL const r = ::GetQueuedCompletionStatus(port, &bytes, &key, + &got, 1000); + DWORD const err = ::GetLastError(); + BOOST_TEST(r == FALSE); + BOOST_TEST_EQ(err, static_cast(ERROR_NETNAME_DELETED)); + // The real call ran first, so the packet is still delivered: + // that is what makes this an error on a real completion. + BOOST_TEST(got == &ov); + BOOST_TEST_EQ(key, static_cast(99)); + BOOST_TEST(c.fired()); + } + std::ignore = ::CloseHandle(port); + } + + void testCompletionFaultDisarmsOnScopeExit() + { + if(!require_hook(sys::GetQueuedCompletionStatus, + "GetQueuedCompletionStatus")) + return; + HANDLE port = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, nullptr, + 0, 1); + BOOST_TEST(port != nullptr); + OVERLAPPED ov{}; + BOOST_TEST(::PostQueuedCompletionStatus(port, 7, 99, &ov) != FALSE); + { + completion_fault_scope c(ERROR_NETNAME_DELETED, 2); + } + DWORD bytes = 0; + ULONG_PTR key = 0; + LPOVERLAPPED got = nullptr; + BOOST_TEST(::GetQueuedCompletionStatus(port, &bytes, &key, &got, 1000) + != FALSE); + BOOST_TEST(got == &ov); + std::ignore = ::CloseHandle(port); + } + + // AcceptEx and ConnectEx are never imported: the library asks + // WSAIoctl for them. The hook has to hand back a pointer of its own + // or those two are unreachable. + void testExtensionPointersAreWrapped() + { + if(!require_hook(sys::WSAIoctl, "WSAIoctl")) + return; + SOCKET s = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST(s != INVALID_SOCKET); + DWORD bytes = 0; + GUID accept_guid = WSAID_ACCEPTEX; + LPFN_ACCEPTEX accept_ex = nullptr; + BOOST_TEST_EQ(::WSAIoctl(s, SIO_GET_EXTENSION_FUNCTION_POINTER, + &accept_guid, sizeof(accept_guid), &accept_ex, sizeof(accept_ex), + &bytes, nullptr, nullptr), 0); + BOOST_TEST(accept_ex != nullptr); + + HMODULE owner = nullptr; + auto const* addr = reinterpret_cast(accept_ex); + BOOST_TEST(::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(addr), &owner) != FALSE); + BOOST_TEST(owner == ::GetModuleHandleW(nullptr)); + + { + fault_scope f(sys::AcceptEx, test_err); + BOOST_TEST(accept_ex(s, s, nullptr, 0, 0, 0, &bytes, nullptr) == + FALSE); + BOOST_TEST(err_is(test_err)); + BOOST_TEST(f.fired()); + } + + GUID connect_guid = WSAID_CONNECTEX; + LPFN_CONNECTEX connect_ex = nullptr; + BOOST_TEST_EQ(::WSAIoctl(s, SIO_GET_EXTENSION_FUNCTION_POINTER, + &connect_guid, sizeof(connect_guid), &connect_ex, + sizeof(connect_ex), &bytes, nullptr, nullptr), 0); + BOOST_TEST(connect_ex != nullptr); + { + fault_scope f(sys::ConnectEx, test_err); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK); + BOOST_TEST(connect_ex(s, reinterpret_cast(&sa), + static_cast(sizeof(sa)), nullptr, 0, &bytes, nullptr) == + FALSE); + BOOST_TEST(err_is(test_err)); + BOOST_TEST(f.fired()); + } + std::ignore = ::closesocket(s); + } + + // The library reaches the two ntdll entry points through + // GetProcAddress, so the substitution has to survive the same + // lookup the library performs. + void testNtPointersAreWrapped() + { + if(!require_hook(sys::GetProcAddress, "GetProcAddress")) + return; + HMODULE ntdll = ::GetModuleHandleW(L"ntdll.dll"); + BOOST_TEST(ntdll != nullptr); + using nt_set_fn = LONG(NTAPI*)(HANDLE, ULONG_PTR*, void*, ULONG, + ULONG); + auto const fn = reinterpret_cast( + reinterpret_cast( + ::GetProcAddress(ntdll, "NtSetInformationFile"))); + BOOST_TEST(fn != nullptr); + HMODULE owner = nullptr; + auto const* addr = reinterpret_cast(fn); + BOOST_TEST(::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(addr), &owner) != FALSE); + BOOST_TEST(owner == ::GetModuleHandleW(nullptr)); + { + fault_scope f(sys::NtSetInformationFile, test_err); + ULONG_PTR iosb[2] = {0, 0}; + void* info[2] = {nullptr, nullptr}; + BOOST_TEST(fn(INVALID_HANDLE_VALUE, iosb, &info, + static_cast(sizeof(info)), 61) != 0); + BOOST_TEST(f.fired()); + } + + // The file services resolve this one through the ANSI spelling + // of the module name, so the substitution has to survive that + // lookup too. + using nt_flush_fn = LONG(NTAPI*)(HANDLE, ULONG, void*, ULONG, + void*); + auto const flush = reinterpret_cast( + reinterpret_cast(::GetProcAddress( + ::GetModuleHandleA("NTDLL"), "NtFlushBuffersFileEx"))); + // Absent before Windows 8; there is nothing to substitute then. + if(!flush) + return; + HMODULE flush_owner = nullptr; + auto const* flush_addr = reinterpret_cast(flush); + BOOST_TEST(::GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + static_cast(flush_addr), &flush_owner) != FALSE); + BOOST_TEST(flush_owner == ::GetModuleHandleW(nullptr)); + { + fault_scope f(sys::NtFlushBuffersFileEx, test_err); + ULONG_PTR iosb[2] = {0, 0}; + BOOST_TEST(flush(INVALID_HANDLE_VALUE, 1, nullptr, 0, iosb) + != 0); + BOOST_TEST(f.fired()); + } + } + + void run() + { + winsock_guard guard; + testFiresOnNth(); + testDisarmsOnScopeExit(); + testFiredScopeStaysFired(); + testOpenFdsProbeWorks(); + testTransparentWhenUnarmed(); + testThreadIsolation(); + testAnyThreadFires(); + testThreadLocalWinsOverAnyThread(); + testTwoArmsCoexist(); + testEveryCensusSymbolFails(); + testReturningTruncatesAndForwards(); + testFreeAddrInfoRunsUnderArm(); + testCompletionFaultRewrites(); + testCompletionFaultDisarmsOnScopeExit(); + testExtensionPointersAreWrapped(); + testNtPointersAreWrapped(); + } +}; + +TEST_SUITE(self_test, "boost.corosio.fault.self"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp new file mode 100644 index 000000000..22a2dce0d --- /dev/null +++ b/test/unit/fault/uring_faults.cpp @@ -0,0 +1,545 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_IO_URING + +#include + +#include +#include + +namespace boost::corosio::test::fault { + +namespace { + +endpoint uring_loopback() +{ + return endpoint(ipv4_address::loopback(), 0); +} + +} // namespace + +struct uring_faults +{ + // The ring is created lazily, so init faults surface from the first + // operation that needs it rather than from the io_context ctor. + void testRingInitFails() + { + auto expect = [](sys s, int err, std::errc code) + { + io_context ioc(io_uring); + fault_scope f(s, err); + expect_system_error([&]{ ioc.run(); }, code); + BOOST_TEST(f.fired()); + }; + expect(sys::io_uring_queue_init_params, ENOMEM, + std::errc::not_enough_memory); + expect(sys::eventfd, EMFILE, std::errc::too_many_files_open); + // The wakeup poll's submit is the only one init issues. + expect(sys::io_uring_submit, EBADF, + std::errc::bad_file_descriptor); + } + + void testRingInitLeaksNothing() + { + int before = open_fds(); + { + io_context ioc(io_uring); + fault_scope f(sys::io_uring_submit, EBADF); + expect_system_error([&]{ ioc.run(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + } + BOOST_TEST_EQ(open_fds(), before); + } + + void testSignalReaderSubmitFails() + { + // A successful add opens the process-global self-pipe and + // installs its handlers; doing that here would disarm the + // tests that fault exactly that setup, so it stays in a child. + in_child([]{ + io_context ioc(io_uring); + signal_set ss(ioc); + std::error_code ec; + bool fired = false; + { + // nth = 2: the first submit arms the wakeup eventfd + // when the ring is created. + fault_scope f(sys::io_uring_submit, EBADF, 2); + ec = ss.add(SIGUSR2); + fired = f.fired(); + } + // Not latched: the registration is retried by the next add(). + return fired && ec == std::errc::bad_file_descriptor && + !ss.add(SIGUSR2) && !ss.clear(); + }); + } + + void testWaitFails() + { + { + io_context ioc(io_uring); + fault_scope f(sys::io_uring_wait_cqe_timeout, EINTR); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(done); + } + { + io_context ioc(io_uring); + fault_scope f(sys::io_uring_wait_cqe_timeout, EBADF); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + } + } + + // The acceptor's close path drains its multishot CQEs; a failing + // submit_and_wait_timeout breaks that loop silently, so the only + // observable is that the fault was reached. + void testAcceptorDrainSubmitFails() + { + io_context ioc(io_uring); + fault_scope f(sys::io_uring_submit_and_wait_timeout, EBADF); + { + tcp_acceptor acc(ioc, uring_loopback()); + tcp_socket s(ioc); + std::error_code aec; + auto accept_body = [&]() -> capy::task<> + { + auto [ec] = co_await acc.accept(s); + aec = ec; + }; + capy::run_async(ioc.get_executor())(accept_body()); + ioc.poll(); + acc.close(); + ioc.poll(); + BOOST_TEST(aec == capy::error::canceled); + } + BOOST_TEST(f.fired()); + } + + // A ring clamped to one SQE spends it on the wakeup poll, and the + // deferred flush that would free it is failed too, so the first + // user op finds the SQ still full after its own flush retry. The + // pair is built with raw syscalls and adopted: connecting through + // a ring this crippled would deadlock before the test began. + void testSqFull() + { + int sv[2]; + if(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + { + BOOST_TEST(false); + return; + } + make_native_adoptable(sv[1]); + fault_scope f(sys::uring_sqe_full, 0); + fault_scope g(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + local_stream_socket b(ioc); + BOOST_TEST(!b.assign(sv[1])); + char buf[8]; + std::error_code rec, rec2; + std::size_t n2 = 0; + auto read_body = [&]() -> capy::task<> + { + { + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 8)); + std::ignore = n; + rec = ec; + } + // The reported eof is spurious, so the peer's next bytes + // still arrive: the follow-up read needs no SQE because the + // speculative readv answers it. + BOOST_TEST_EQ(::write(sv[0], "abcd", 4), 4); + { + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 8)); + rec2 = ec; + n2 = n; + } + }; + capy::run_async(ioc.get_executor())(read_body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(g.fired()); + // The SQ-full path writes EAGAIN into the op's error output and + // then queues the op as completed; the read handler re-decodes + // the untouched res == 0 as a zero-byte read, so the caller sees + // eof on a socket whose peer is still open rather than the + // EAGAIN the submit path wrote. + BOOST_TEST(rec == capy::error::eof); + BOOST_TEST(b.is_open()); + BOOST_TEST(!rec2); + BOOST_TEST_EQ(n2, 4u); + BOOST_TEST_EQ(std::memcmp(buf, "abcd", 4), 0); + ::close(sv[0]); + } + + void testConnectCqeRewrite() + { + io_context ioc(io_uring); + tcp_acceptor acc(ioc, uring_loopback()); + tcp_socket c(ioc); + BOOST_TEST(!c.open(tcp::v4())); + std::error_code cec; + bool fired = false; + { + cqe_fault_scope q( + c.native_handle(), IORING_OP_CONNECT, -ECONNREFUSED); + auto connect_body = [&]() -> capy::task<> + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + cec = ec; + }; + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + fired = q.fired(); + } + BOOST_TEST(fired); + BOOST_TEST(cec == std::errc::connection_refused); + // A refused connect reports through the return channel and + // leaves the descriptor with the caller. + BOOST_TEST(c.is_open()); + } + + // The multishot accept SQE is prepared by listen(), so the arm has + // to be in place before the listener is armed rather than before + // the accept call. + void testAcceptCqeRewrite() + { + io_context ioc(io_uring); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(uring_loopback())); + tcp_socket c(ioc), s(ioc); + std::error_code aec; + bool fired = false; + { + cqe_fault_scope q( + acc.native_handle(), IORING_OP_ACCEPT, -EMFILE); + BOOST_TEST(!acc.listen()); + auto accept_body = [&]() -> capy::task<> + { + auto [ec] = co_await acc.accept(s); + aec = ec; + }; + capy::run_async(ioc.get_executor())(accept_body()); + auto connect_body = [&]() -> capy::task<> + { + std::ignore = co_await c.connect(acc.local_endpoint()); + }; + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + fired = q.fired(); + } + BOOST_TEST(fired); + BOOST_TEST(aec == std::errc::too_many_files_open); + // A failed accept adopts nothing into the peer socket. + BOOST_TEST(!s.is_open()); + } + + // Stream reads and writes try a synchronous readv/sendmsg before + // they submit, so each rewrite needs the speculation pushed off the + // fast path with its own EAGAIN arm. + void testStreamCqeRewrites() + { + io_context ioc(io_uring); + auto [a, b] = test::make_socket_pair(ioc); + char buf[8] = "1234567"; + std::error_code sec, rec, pec, smec, rvec; + bool sfired = false, rfired = false, pfired = false; + bool smfired = false, rvfired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::sendmsg, EAGAIN); + cqe_fault_scope q( + a.native_handle(), IORING_OP_SEND, -EPIPE); + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + sec = ec; + BOOST_TEST(f.fired()); + sfired = q.fired(); + } + { + fault_scope f(sys::readv, EAGAIN); + cqe_fault_scope q( + b.native_handle(), IORING_OP_RECV, -ECONNRESET); + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + rfired = q.fired(); + } + // The two faulted completions carried a negative res, which + // never re-arms speculation, so the scatter-gather pair + // below reaches its SQE without an EAGAIN arm of its own. + { + cqe_fault_scope q( + a.native_handle(), IORING_OP_SENDMSG, -ENOBUFS); + std::array cb{ + capy::const_buffer(buf, 4), + capy::const_buffer(buf + 4, 3)}; + auto [ec, n] = co_await a.write_some(cb); + std::ignore = n; + smec = ec; + smfired = q.fired(); + } + { + char x[4] = {}, y[4] = {}; + cqe_fault_scope q( + b.native_handle(), IORING_OP_READV, -EIO); + std::array mb{ + capy::mutable_buffer(x, 4), + capy::mutable_buffer(y, 3)}; + auto [ec, n] = co_await b.read_some(mb); + std::ignore = n; + rvec = ec; + rvfired = q.fired(); + } + { + // The rewritten RECV really ran, so b's buffer is empty + // again; the poll needs data on the other side or it + // would park forever instead of delivering a CQE. + auto [ec, n] = co_await b.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + cqe_fault_scope q( + a.native_handle(), IORING_OP_POLL_ADD, -EBADF); + auto [ec] = co_await a.wait(wait_type::read); + pec = ec; + pfired = q.fired(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(sfired); + BOOST_TEST(rfired); + BOOST_TEST(smfired); + BOOST_TEST(rvfired); + BOOST_TEST(pfired); + BOOST_TEST(sec == std::errc::broken_pipe); + BOOST_TEST(rec == std::errc::connection_reset); + BOOST_TEST(smec == std::errc::no_buffer_space); + BOOST_TEST(rvec == std::errc::io_error); + BOOST_TEST(pec == std::errc::bad_file_descriptor); + } + + void testDatagramCqeRewrites() + { + io_context ioc(io_uring); + udp_socket a(ioc), b(ioc); + BOOST_TEST(!a.open(udp::v4())); + BOOST_TEST(!b.open(udp::v4())); + BOOST_TEST(!a.bind(uring_loopback())); + BOOST_TEST(!b.bind(uring_loopback())); + char buf[8] = "1234567"; + std::error_code sec, rec; + bool sfired = false, rfired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope f(sys::sendmsg, EAGAIN); + cqe_fault_scope q( + a.native_handle(), IORING_OP_SENDMSG, -EIO); + auto [ec, n] = co_await a.send_to( + capy::const_buffer(buf, 7), b.local_endpoint()); + std::ignore = n; + sec = ec; + BOOST_TEST(f.fired()); + sfired = q.fired(); + } + { + fault_scope f(sys::recvmsg, EAGAIN); + cqe_fault_scope q( + b.native_handle(), IORING_OP_RECVMSG, -EIO); + endpoint from; + auto [ec, n] = co_await b.recv_from( + capy::mutable_buffer(buf, 7), from); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + rfired = q.fired(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(sfired); + BOOST_TEST(rfired); + BOOST_TEST(sec == std::errc::io_error); + BOOST_TEST(rec == std::errc::io_error); + } + + // Ring file I/O never reaches preadv/pwritev, so the failure that + // the POSIX file tests inject through those calls is only + // reachable here, on the completion of the READV/WRITEV SQE. + void testFileCqeRewrites() + { + io_context ioc(io_uring); + auto sf_path = temp_path("uring_sf"); + auto rf_path = temp_path("uring_raf"); + stream_file sf(ioc); + random_access_file rf(ioc); + BOOST_TEST(!sf.open(sf_path, + file_base::read_write | file_base::create)); + BOOST_TEST(!rf.open(rf_path, + file_base::read_write | file_base::create)); + char buf[8] = "1234567"; + std::error_code swec, srec, rwec, rrec; + bool swf = false, srf = false, rwf = false, rrf = false; + auto body = [&]() -> capy::task<> + { + { + cqe_fault_scope q( + sf.native_handle(), IORING_OP_WRITEV, -EIO); + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + swec = ec; + swf = q.fired(); + } + { + // A faulted completion is not sticky: the file still + // takes writes, and this one gives the read below + // something to find. + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + auto [ec, pos] = sf.seek(0, file_base::seek_set); + std::ignore = pos; + BOOST_TEST(!ec); + } + { + cqe_fault_scope q( + sf.native_handle(), IORING_OP_READV, -EIO); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + srec = ec; + srf = q.fired(); + } + { + cqe_fault_scope q( + rf.native_handle(), IORING_OP_WRITEV, -EIO); + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + rwec = ec; + rwf = q.fired(); + } + { + // Same for the random-access file: the faulted + // completion leaves it usable, and this write seeds the + // read below. + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + cqe_fault_scope q( + rf.native_handle(), IORING_OP_READV, -EIO); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + rrec = ec; + rrf = q.fired(); + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(swf); + BOOST_TEST(srf); + BOOST_TEST(rwf); + BOOST_TEST(rrf); + BOOST_TEST(swec == std::errc::io_error); + BOOST_TEST(srec == std::errc::io_error); + BOOST_TEST(rwec == std::errc::io_error); + BOOST_TEST(rrec == std::errc::io_error); + sf.close(); + rf.close(); + ::unlink(sf_path.c_str()); + ::unlink(rf_path.c_str()); + } + + void run() + { + if(skip_under_valgrind()) + return; + testRingInitFails(); + testRingInitLeaksNothing(); + testSignalReaderSubmitFails(); + testWaitFails(); + testAcceptorDrainSubmitFails(); + testSqFull(); + testConnectCqeRewrite(); + testAcceptCqeRewrite(); + testStreamCqeRewrites(); + testDatagramCqeRewrites(); + testFileCqeRewrites(); + } +}; + +TEST_SUITE(uring_faults, "boost.corosio.fault.io_uring"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/win_faults.cpp b/test/unit/fault/win_faults.cpp new file mode 100644 index 000000000..a10dc2bb7 --- /dev/null +++ b/test/unit/fault/win_faults.cpp @@ -0,0 +1,630 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#if BOOST_COROSIO_HAS_IOCP + +namespace boost::corosio::test::fault { + +namespace { + +void remove_file(std::string const& path) +{ + std::error_code ec; + std::ignore = std::filesystem::remove(std::filesystem::path(path), ec); +} + +} // namespace + +/* Faults on the Windows entry points that are not the IOCP backend's + own: the file services, the resolver, host_name and the CRT signal + registration. The IOCP socket paths live in iocp_faults.cpp. +*/ +struct win_common_faults +{ + void testHostNameFails() + { + { + // The size query is the only call that reports through + // GetLastError alone, and host_name passes its code + // through untouched. + fault_scope f(sys::GetComputerNameExW, ERROR_ACCESS_DENIED); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(name.empty()); + } + { + // 1 is the size query, which has to report ERROR_MORE_DATA + // for the fetch to happen at all. + fault_scope f(sys::GetComputerNameExW, ERROR_INVALID_PARAMETER, 2); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(name.empty()); + } + { + // Sizing conversion: returns 0, so `needed <= 0`. + fault_scope f(sys::WideCharToMultiByte, ERROR_INSUFFICIENT_BUFFER); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INSUFFICIENT_BUFFER)); + BOOST_TEST(name.empty()); + } + { + // Converting conversion: 0 written where `needed` was + // positive. + fault_scope f(sys::WideCharToMultiByte, ERROR_INVALID_PARAMETER, 2); + auto [ec, name] = host_name(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(name.empty()); + } + // Unfaulted, the call must still work: the arms above are the + // only reason any of them failed. + auto [ec, name] = host_name(); + BOOST_TEST(!ec); + BOOST_TEST(!name.empty()); + } + + void testStreamFileOpenFails() + { + io_context ioc(iocp); + auto path = temp_path("winsf"); + stream_file sf(ioc); + { + fault_scope f(sys::CreateFileW, ERROR_ACCESS_DENIED); + auto ec = sf.open(path, file_base::read_write | + file_base::create); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(!sf.is_open()); + } + // The handle exists when the association fails, so the + // failure path owns closing it. + expect_no_handle_leak([&]{ + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + auto ec = sf.open(path, file_base::read_write | + file_base::create); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(!sf.is_open()); + }); + // create|truncate lowers to OPEN_ALWAYS plus an explicit + // SetEndOfFile; every other mode leaves it to the disposition. + expect_no_handle_leak([&]{ + fault_scope f(sys::SetEndOfFile, ERROR_DISK_FULL); + auto ec = sf.open(path, file_base::read_write | + file_base::create | file_base::truncate); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_DISK_FULL)); + BOOST_TEST(!sf.is_open()); + }); + // Only an appending open seeds its own offset from the file + // size. + expect_no_handle_leak([&]{ + fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); + auto ec = sf.open(path, file_base::write_only | + file_base::create | file_base::append); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST(!sf.is_open()); + }); + remove_file(path); + } + + void testStreamFileSyncOps() + { + io_context ioc(iocp); + auto path = temp_path("winsf2"); + stream_file sf(ioc); + BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + { + fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); + expect_system_error( + [&]{ std::ignore = sf.size(); }, + win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::SetFilePointerEx, ERROR_INVALID_PARAMETER); + BOOST_TEST(sf.resize(16) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::SetEndOfFile, ERROR_DISK_FULL); + BOOST_TEST(sf.resize(16) == win_err(ERROR_DISK_FULL)); + BOOST_TEST(f.fired()); + } + { + // sync_data tries the data-only NT flush first and only + // falls back to FlushFileBuffers when that fails, so the + // fallback needs both arms. Where the NT entry point never + // resolved, its arm has nothing to fail and the fallback + // was already the only path. + fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); + fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); + BOOST_TEST(sf.sync_data() == win_err(ERROR_WRITE_FAULT)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); + BOOST_TEST(sf.sync_all() == win_err(ERROR_WRITE_FAULT)); + BOOST_TEST(f.fired()); + } + { + // Only seek_end asks the file for its size. + fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); + auto [ec, pos] = sf.seek(0, file_base::seek_end); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST_EQ(pos, 0u); + } + sf.close(); + remove_file(path); + } + + void testStreamFileIoFails() + { + io_context ioc(iocp); + auto path = temp_path("winsf3"); + stream_file sf(ioc); + BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + char buf[8] = "1234567"; + std::error_code wec, rec, cec, eec; + std::size_t rn = 99, en = 99; + auto t = [&]() -> capy::task<> + { + { + fault_scope f(sys::WriteFile, ERROR_ACCESS_DENIED); + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await sf.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + auto [ec, pos] = sf.seek(0, file_base::seek_set); + std::ignore = pos; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::ReadFile, ERROR_ACCESS_DENIED); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + rec = ec; + rn = n; + BOOST_TEST(f.fired()); + } + { + // The kernel result of a queued read cannot be armed + // where it was started; the completion carries it. + completion_fault_scope q(ERROR_LOCK_VIOLATION); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + std::ignore = n; + cec = ec; + BOOST_TEST(q.fired()); + } + { + // A zero-length ReadFile completes with zero bytes, + // which the stream contract reads as end of file. + auto f = fault_scope::returning(sys::ReadFile, 0); + auto [ec, n] = co_await sf.read_some( + capy::mutable_buffer(buf, 7)); + eec = ec; + en = n; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(wec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(rec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST_EQ(rn, 0u); + BOOST_TEST(cec == win_err(ERROR_LOCK_VIOLATION)); + BOOST_TEST(eec == capy::error::eof); + BOOST_TEST_EQ(en, 0u); + sf.close(); + remove_file(path); + } + + void testRandomAccessFileFails() + { + io_context ioc(iocp); + auto path = temp_path("winraf"); + random_access_file rf(ioc); + { + fault_scope f(sys::CreateFileW, ERROR_ACCESS_DENIED); + BOOST_TEST(rf.open(path, file_base::read_write | + file_base::create) == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(f.fired()); + } + expect_no_handle_leak([&]{ + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST(rf.open(path, file_base::read_write | + file_base::create) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + }); + BOOST_TEST(!rf.open(path, file_base::read_write | file_base::create)); + { + fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); + expect_system_error( + [&]{ std::ignore = rf.size(); }, + win_err(ERROR_INVALID_HANDLE)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::SetFilePointerEx, ERROR_INVALID_PARAMETER); + BOOST_TEST(rf.resize(16) == win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::SetEndOfFile, ERROR_DISK_FULL); + BOOST_TEST(rf.resize(16) == win_err(ERROR_DISK_FULL)); + BOOST_TEST(f.fired()); + } + { + fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); + fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); + BOOST_TEST(rf.sync_data() == win_err(ERROR_WRITE_FAULT)); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); + BOOST_TEST(rf.sync_all() == win_err(ERROR_WRITE_FAULT)); + BOOST_TEST(f.fired()); + } + char buf[8] = "1234567"; + std::error_code wec, rec, cec, eec; + auto t = [&]() -> capy::task<> + { + { + fault_scope f(sys::WriteFile, ERROR_ACCESS_DENIED); + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + auto [ec, n] = co_await rf.write_some_at( + 0, capy::const_buffer(buf, 7)); + std::ignore = n; + BOOST_TEST(!ec); + } + { + fault_scope f(sys::ReadFile, ERROR_ACCESS_DENIED); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + completion_fault_scope q(ERROR_LOCK_VIOLATION); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + cec = ec; + BOOST_TEST(q.fired()); + } + { + auto f = fault_scope::returning(sys::ReadFile, 0); + auto [ec, n] = co_await rf.read_some_at( + 0, capy::mutable_buffer(buf, 7)); + std::ignore = n; + eec = ec; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(wec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(rec == win_err(ERROR_ACCESS_DENIED)); + BOOST_TEST(cec == win_err(ERROR_LOCK_VIOLATION)); + BOOST_TEST(eec == capy::error::eof); + rf.close(); + remove_file(path); + } + + void testConnectPairFails() + { + io_context ioc(iocp); + // The pair is built by hand out of a listening AF_UNIX socket, + // a worker that connects and a blocking accept here; each of + // those four calls has its own failure path. + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_family_not_supported); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::bind, WSAEADDRINUSE); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::address_in_use); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::listen, WSAEOPNOTSUPP); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::operation_not_supported); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + // The worker still connects, so the accept fault is the + // one caller-side failure that does not strand it. + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::accept, WSAENOTSOCK); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_a_socket); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + // Adoption of the first descriptor fails; both are the + // library's to close. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_a_socket); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + // Unfaulted, a pair still forms. + local_stream_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + } + + void testAvailableThrows() + { + io_context ioc(iocp); + local_stream_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + // available() reports the raw Winsock code rather than + // routing it through make_err + // (src/corosio/src/local_stream_socket.cpp:118-123). + fault_scope f(sys::ioctlsocket, WSAEINVAL); + expect_system_error( + [&]{ std::ignore = a.available(); }, win_err(WSAEINVAL)); + BOOST_TEST(f.fired()); + BOOST_TEST(a.is_open()); + } + + void testResolverFails() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code fec, rec; + auto t = [&]() -> capy::task<> + { + { + // GetAddrInfoExW reports a synchronous failure through + // its return value and the last-error slot alike. + fault_scope f(sys::GetAddrInfoExW, WSAEAFNOSUPPORT); + auto [ec, results] = co_await r.resolve("localhost", "80"); + std::ignore = results; + fec = ec; + BOOST_TEST(f.fired()); + } + { + // GetNameInfoW blocks, so it runs on a pool thread + // where the thread-local arms are never consulted. + fault_scope f(sys::GetNameInfoW, WSAEAFNOSUPPORT, 1, + any_thread); + auto [ec, result] = co_await r.resolve( + endpoint(ipv4_address::loopback(), 80)); + std::ignore = result; + rec = ec; + BOOST_TEST(f.fired()); + } + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(fec == std::errc::address_family_not_supported); + BOOST_TEST(rec == std::errc::address_family_not_supported); + } + + // resolver_detail::to_wide gives up when MultiByteToWideChar + // reports a length of zero or less and hands back an empty string. + // Faulting the length probe of each conversion in turn leaves both + // the node and the service name empty, and win_resolver::resolve + // passes a null pointer for an empty one -- a lookup with neither + // is the documented WSAHOST_NOT_FOUND. The conversions run on the + // calling thread, so a thread-local arm reaches them. + void testResolverWideConversionFails() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code rec; + bool armed_fired = false; + auto t = [&]() -> capy::task<> + { + // The host conversion is calls 1 and 2 and the service + // conversion 3 and 4; failing the first probe skips call 2, + // so nth 2 is the service's own probe. + fault_scope host(sys::MultiByteToWideChar, + ERROR_INVALID_PARAMETER, 1); + fault_scope service(sys::MultiByteToWideChar, + ERROR_INVALID_PARAMETER, 2); + auto [ec, results] = co_await r.resolve("localhost", "80"); + std::ignore = results; + rec = ec; + armed_fired = host.fired() && service.fired(); + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(armed_fired); + if(rec != win_err(WSAHOST_NOT_FOUND)) + std::fprintf(stderr, + "fault harness: name-less lookup reported %d (%s)\n", + rec.value(), rec.message().c_str()); + BOOST_TEST(rec == win_err(WSAHOST_NOT_FOUND)); + } + + void testResolverCancelIgnored() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code rec; + bool cancel_fired = false; + auto body = [&]() -> capy::task<> + { + auto [ec, results] = co_await r.resolve( + "corosio-fault-nonexistent.invalid", "80"); + std::ignore = results; + rec = ec; + }; + auto canceller = [&]() -> capy::task<> + { + fault_scope f(sys::GetAddrInfoExCancel, WSAEINVAL); + r.cancel(); + cancel_fired = f.fired(); + co_return; + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(canceller()); + ioc.run(); + // A lookup answered from the resolver cache completes before + // the canceller runs and never records a cancel handle, so the + // discarded return value is only asserted on the pending path. + if(rec == capy::error::canceled) + BOOST_TEST(cancel_fired); + } + + void run() + { + testHostNameFails(); + testStreamFileOpenFails(); + testStreamFileSyncOps(); + testStreamFileIoFails(); + testRandomAccessFileFails(); + testConnectPairFails(); + testAvailableThrows(); + testResolverFails(); + testResolverWideConversionFails(); + testResolverCancelIgnored(); + } +}; + +TEST_SUITE(win_common_faults, "boost.corosio.fault.win"); + +/* The CRT's signal registration is process-wide and counted, so a + second signal_set in the same process would find the handler already + installed and never reach the faulted call. There is no fork on + Windows to isolate that in, so these live in a suite of their own: + CTest runs each suite as its own process. +*/ +struct win_signal_faults +{ + // SIGTERM is defined by every Windows CRT and is never raised by + // the OS, so registering and restoring it disturbs nothing. + static constexpr int signum = SIGTERM; + + void testAddFails() + { + io_context ioc(iocp); + signal_set ss(ioc); + { + fault_scope f(sys::signal, ERROR_INVALID_PARAMETER); + auto ec = ss.add(signum); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + } + // Not latched: the failed add left no registration behind, so + // the retry installs the handler. + BOOST_TEST(!ss.add(signum)); + BOOST_TEST(!ss.clear()); + } + + void testRemoveFails() + { + io_context ioc(iocp); + signal_set ss(ioc); + BOOST_TEST(!ss.add(signum)); + { + fault_scope f(sys::signal, ERROR_INVALID_PARAMETER); + auto ec = ss.remove(signum); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + } + // The failed remove returns before unlinking, so the + // registration is still there for the retry to take out + // (win_signals::remove_signal). + BOOST_TEST(!ss.remove(signum)); + BOOST_TEST(!ss.clear()); + } + + void testClearFails() + { + io_context ioc(iocp); + signal_set ss(ioc); + BOOST_TEST(!ss.add(signum)); + { + fault_scope f(sys::signal, ERROR_INVALID_PARAMETER); + auto ec = ss.clear(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + } + // clear() reports the first failure but still unlinks every + // registration, so the set is empty either way. + BOOST_TEST(!ss.clear()); + BOOST_TEST(!ss.add(signum)); + BOOST_TEST(!ss.clear()); + } + + void run() + { + testAddFails(); + testRemoveFails(); + testClearFails(); + } +}; + +TEST_SUITE(win_signal_faults, "boost.corosio.fault.win.signals"); + +} // boost::corosio::test::fault + +#endif From c70d523255df289b8b14c27f8a3fdf5a0ecc1040 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Wed, 26 Aug 2026 17:08:49 +0200 Subject: [PATCH 02/34] ci: build the tests in the FreeBSD CMake leg The Boost superproject defaults BUILD_TESTING to OFF, so the FreeBSD CMake step built nothing under --target tests and ctest reported no tests. Every other CMake leg gets the option from the cpp-actions workflow. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1bca8054..3b6396ed3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -861,6 +861,7 @@ jobs: cd boost-root cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_TESTING=ON \ -DBOOST_INCLUDE_LIBRARIES="${{ steps.patch.outputs.module }}" \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON cmake --build build --target tests -j$(sysctl -n hw.ncpu) From ca8d5c4105acf7284de5ad86ba83fac96b94541d Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:14 +0200 Subject: [PATCH 03/34] fix(select): re-acquire the lock after an interrupted select An EINTR or EBADF from select() returned from run_task with the scheduler lock released, so the next dispatch pushed to the ready queue unsynchronized and then unlocked a lock it did not own, raising std::system_error out of io_context::run(). The other reactors fall through to the same epilogue that re-acquires the lock; select now does too, and run_task documents that it returns with the lock owned. --- .../detail/reactor/reactor_scheduler.hpp | 9 +++- .../native/detail/select/select_scheduler.hpp | 9 ++-- test/unit/fault/select_faults.cpp | 47 +++++++++++++------ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp index 92b5fd919..aa0d74833 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp @@ -331,7 +331,14 @@ class reactor_scheduler }; task_op task_op_; - /// Run the platform-specific reactor poll. + /** Run the platform-specific reactor poll. + + @par Postconditions + `lock` is owned on return, however the poll ended. An + implementation that unlocks around the blocking call owes the + caller a matching re-acquire on every path out, including the + errors it retries rather than reports. + */ virtual void run_task(lock_type& lock, context_type* ctx, long timeout_us) = 0; diff --git a/include/boost/corosio/native/detail/select/select_scheduler.hpp b/include/boost/corosio/native/detail/select/select_scheduler.hpp index 470765342..a885a96e7 100644 --- a/include/boost/corosio/native/detail/select/select_scheduler.hpp +++ b/include/boost/corosio/native/detail/select/select_scheduler.hpp @@ -402,11 +402,14 @@ select_scheduler::run_task( // EINTR: signal interrupted select(), just retry. // EBADF: an fd was closed between snapshot and select(); retry // with a fresh snapshot from registered_descs_. + // Both fall through with no ready descriptors rather than + // returning: the caller handed this function an owned lock that + // only the epilogue below re-acquires. if (ready < 0) { - if (errno == EINTR || errno == EBADF) - return; - detail::throw_system_error(make_err(errno), "select"); + if (errno != EINTR && errno != EBADF) + detail::throw_system_error(make_err(errno), "select"); + ready = 0; } // Process timers outside the lock diff --git a/test/unit/fault/select_faults.cpp b/test/unit/fault/select_faults.cpp index 797722f09..7d6ac6767 100644 --- a/test/unit/fault/select_faults.cpp +++ b/test/unit/fault/select_faults.cpp @@ -180,22 +180,39 @@ struct select_faults void testRunLoopFaults() { - io_context ioc(select); - // EINTR and EBADF are the two codes select() retries; every - // other one leaves the run loop through an exception. Only the - // exceptional branch is driven from a test: arming either - // retried code aborts the process instead of looping, so it is - // left uncovered rather than asserted. - fault_scope f(sys::select, EINVAL); - auto body = [&]() -> capy::task<> + // EINTR and EBADF are the two codes select() retries: the poll + // is abandoned for this round and the run loop keeps going, so + // the delay armed before it still fires. + for(int err : {EINTR, EBADF}) { - std::ignore = co_await corosio::delay( - std::chrono::milliseconds(1)); - }; - capy::run_async(ioc.get_executor())(body()); - expect_system_error([&]{ ioc.run(); }, - std::errc::invalid_argument); - BOOST_TEST(f.fired()); + io_context ioc(select); + fault_scope f(sys::select, err); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(done); + } + // Every other code leaves the run loop through an exception. + { + io_context ioc(select); + fault_scope f(sys::select, EINVAL); + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + capy::run_async(ioc.get_executor())(body()); + expect_system_error([&]{ ioc.run(); }, + std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } } void run() From aa92e57665d9b683785a05d17db1188d715975fa Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:15 +0200 Subject: [PATCH 04/34] fix(io_uring): report EAGAIN when the submission queue is full The SQ-full path wrote EAGAIN to the op's error slot but left res at zero, and every handler re-derives the result from res: a read reported eof, a write reported success with no bytes moved, and a connect or wait reported success for an operation that was never submitted. The path now sets res to -EAGAIN so every op kind decodes the same retryable error. --- .../detail/io_uring/io_uring_socket_ops.hpp | 23 ++- test/unit/fault/uring_faults.cpp | 158 +++++++++++++----- 2 files changed, 128 insertions(+), 53 deletions(-) diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp index ffa2ed2a6..3b552de12 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp @@ -437,9 +437,9 @@ struct uring_connect_op : io_uring_op Subsequent submitters in the same batch piggyback — their SQEs sit in the user-space SQ ring until that op dispatches. - On SQ-ring exhaustion (after one flush retry), surfaces `EAGAIN` - on `*op->ec_out` and queues the op as completed so its handler - dispatches on the next `do_one` cycle. + On SQ-ring exhaustion (after one flush retry), completes the op + with `EAGAIN` and queues it so its handler dispatches on the next + `do_one` cycle, exactly as if the kernel had returned that error. @pre `op->prep_func != nullptr`. @@ -466,11 +466,18 @@ io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept if (!sqe) { // SQ stayed full after one flush — synchronous failure path. - // Surface EAGAIN and queue the op as completed so do_one - // dispatches the handler. The caller's work_started() already - // counted this op. (CAS path is not entered here.) - if (op->ec_out) - *op->ec_out = make_err(EAGAIN); + // Report EAGAIN the way a CQE would, because every handler + // re-derives its result from `res`: a code written straight + // to ec_out is overwritten on the way out, and a res left at + // zero reads as end-of-file, a zero-byte write, or a + // successful connect that never happened. Queue the op as + // completed so do_one dispatches the handler. The caller's + // work_started() already counted this op, with one exception: + // the multishot accept arm deliberately counts nothing, so a + // failure here spends a work_finished() it never matched. Its + // handler is a no-op, which makes that an accounting slip and + // not a use-after-free. (CAS path is not entered here.) + op->res = -EAGAIN; typename io_uring_scheduler::lock_type lock(sched.dispatch_mutex()); sched.push_completed_locked(op); return; diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp index 22a2dce0d..a6278cb3b 100644 --- a/test/unit/fault/uring_faults.cpp +++ b/test/unit/fault/uring_faults.cpp @@ -169,60 +169,128 @@ struct uring_faults // A ring clamped to one SQE spends it on the wakeup poll, and the // deferred flush that would free it is failed too, so the first - // user op finds the SQ still full after its own flush retry. The - // pair is built with raw syscalls and adopted: connecting through - // a ring this crippled would deadlock before the test began. + // user op finds the SQ still full after its own flush retry. Every + // op kind reports the same EAGAIN there, whatever it was going to + // do with the SQE. The pairs are built with raw syscalls and + // adopted: connecting through a ring this crippled would deadlock + // before the test began. void testSqFull() { - int sv[2]; - if(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + // A read that never reached the kernel is not an end of file. { - BOOST_TEST(false); - return; + int sv[2]; + if(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + { + BOOST_TEST(false); + return; + } + make_native_adoptable(sv[1]); + fault_scope f(sys::uring_sqe_full, 0); + fault_scope g(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + local_stream_socket b(ioc); + BOOST_TEST(!b.assign(sv[1])); + char buf[8]; + std::error_code rec, rec2; + std::size_t n1 = 1, n2 = 0; + auto read_body = [&]() -> capy::task<> + { + { + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 8)); + rec = ec; + n1 = n; + } + // Nothing was consumed, so the peer's next bytes still + // arrive: the follow-up read needs no SQE because the + // speculative readv answers it. + BOOST_TEST_EQ(::write(sv[0], "abcd", 4), 4); + { + auto [ec, n] = co_await b.read_some( + capy::mutable_buffer(buf, 8)); + rec2 = ec; + n2 = n; + } + }; + capy::run_async(ioc.get_executor())(read_body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(g.fired()); + BOOST_TEST(rec == std::errc::resource_unavailable_try_again); + BOOST_TEST_EQ(n1, 0u); + BOOST_TEST(b.is_open()); + BOOST_TEST(!rec2); + BOOST_TEST_EQ(n2, 4u); + BOOST_TEST_EQ(std::memcmp(buf, "abcd", 4), 0); + ::close(sv[0]); } - make_native_adoptable(sv[1]); - fault_scope f(sys::uring_sqe_full, 0); - fault_scope g(sys::io_uring_submit_and_get_events, EBADF); - io_context ioc(io_uring); - local_stream_socket b(ioc); - BOOST_TEST(!b.assign(sv[1])); - char buf[8]; - std::error_code rec, rec2; - std::size_t n2 = 0; - auto read_body = [&]() -> capy::task<> + // A write that never reached the kernel moved no bytes, so + // reporting success with a zero count would lose the payload. { + int sv[2]; + if(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) { - auto [ec, n] = co_await b.read_some( - capy::mutable_buffer(buf, 8)); - std::ignore = n; - rec = ec; + BOOST_TEST(false); + return; } - // The reported eof is spurious, so the peer's next bytes - // still arrive: the follow-up read needs no SQE because the - // speculative readv answers it. - BOOST_TEST_EQ(::write(sv[0], "abcd", 4), 4); + make_native_adoptable(sv[1]); + fault_scope f(sys::uring_sqe_full, 0); + fault_scope g(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + local_stream_socket b(ioc); + BOOST_TEST(!b.assign(sv[1])); + std::error_code wec; + std::size_t wn = 1; + auto write_body = [&]() -> capy::task<> { - auto [ec, n] = co_await b.read_some( - capy::mutable_buffer(buf, 8)); - rec2 = ec; - n2 = n; + // The socket is writable, so the speculative sendmsg + // would answer the write without ever needing an SQE. + fault_scope s(sys::sendmsg, EAGAIN); + auto [ec, n] = co_await b.write_some( + capy::const_buffer("abcd", 4)); + wec = ec; + wn = n; + BOOST_TEST(s.fired()); + }; + capy::run_async(ioc.get_executor())(write_body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(g.fired()); + BOOST_TEST(wec == std::errc::resource_unavailable_try_again); + BOOST_TEST_EQ(wn, 0u); + BOOST_TEST(b.is_open()); + ::close(sv[0]); + } + // A readiness wait carries no byte count to give it away: an + // unsubmitted poll that reported success would have the caller + // read a socket nothing said was readable. + { + int sv[2]; + if(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + { + BOOST_TEST(false); + return; } - }; - capy::run_async(ioc.get_executor())(read_body()); - ioc.run(); - BOOST_TEST(f.fired()); - BOOST_TEST(g.fired()); - // The SQ-full path writes EAGAIN into the op's error output and - // then queues the op as completed; the read handler re-decodes - // the untouched res == 0 as a zero-byte read, so the caller sees - // eof on a socket whose peer is still open rather than the - // EAGAIN the submit path wrote. - BOOST_TEST(rec == capy::error::eof); - BOOST_TEST(b.is_open()); - BOOST_TEST(!rec2); - BOOST_TEST_EQ(n2, 4u); - BOOST_TEST_EQ(std::memcmp(buf, "abcd", 4), 0); - ::close(sv[0]); + make_native_adoptable(sv[1]); + fault_scope f(sys::uring_sqe_full, 0); + fault_scope g(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + local_stream_socket b(ioc); + BOOST_TEST(!b.assign(sv[1])); + std::error_code pec; + auto wait_body = [&]() -> capy::task<> + { + auto [ec] = co_await b.wait(wait_type::read); + pec = ec; + }; + capy::run_async(ioc.get_executor())(wait_body()); + ioc.run(); + BOOST_TEST(f.fired()); + BOOST_TEST(g.fired()); + BOOST_TEST(pec == std::errc::resource_unavailable_try_again); + BOOST_TEST(b.is_open()); + ::close(sv[0]); + } } void testConnectCqeRewrite() From 760cdf2bf7c69edb92329f1ee60c954b0be1fb45 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:16 +0200 Subject: [PATCH 05/34] fix(io_uring): report a signal reader that could not be armed prep_multishot_poll gave up silently when no SQE was available after one flush, and register_signal_reader then returned success from a submit of nothing; the signal service latched the reader as registered and every async_wait hung. The poll now reports whether it was armed, register_signal_reader returns EAGAIN when it was not, and the registration is retried by the next add(). A full submission queue reports EAGAIN on every path, and the rulebook lists it among the corosio-generated codes. --- doc/error-handling-rulebook.md | 4 ++- .../detail/io_uring/io_uring_scheduler.hpp | 32 ++++++++++++------- test/unit/fault/uring_faults.cpp | 28 ++++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/doc/error-handling-rulebook.md b/doc/error-handling-rulebook.md index a3a44faec..9c7db31b9 100644 --- a/doc/error-handling-rulebook.md +++ b/doc/error-handling-rulebook.md @@ -157,7 +157,9 @@ second channel: `filename_too_long`, `already_connected` (`connect_pair` on an open socket), `no_such_device_or_address` (`corosio::connect` with no viable - candidate). + candidate), + `resource_unavailable_try_again` (io_uring submission queue + exhausted, for a submitted op and for the signal reader alike). - Portable comparison comes from **normalizing at the boundary**: the Windows `make_err` maps the contracted WSA/Win32 codes to generic-category `errc` values (`WSAEOPNOTSUPP`, `WSAENOTSOCK`, 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 0bba10032..7a8e3ed0c 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 @@ -41,6 +41,7 @@ #include #include #include +#include #include #include @@ -501,7 +502,7 @@ class BOOST_COROSIO_DECL io_uring_scheduler final std::size_t do_one(long timeout_us); void process_completions(); void drain_wakeup_eventfd() const noexcept; - void prep_multishot_poll(int fd, void* data) noexcept; + bool prep_multishot_poll(int fd, void* data) noexcept; void lazy_init_ring_unlocked() const; }; @@ -783,14 +784,15 @@ io_uring_scheduler::drain_wakeup_eventfd() const noexcept wakeup_armed_.store(false, std::memory_order_release); } -inline void +inline bool io_uring_scheduler::prep_multishot_poll(int fd, void* data) noexcept { // Prepare a multishot POLLIN SQE on `fd` tagged with `data`. Caller holds // ring_mutex_ and flushes separately (re-arm sites ride the batch submit; - // register/init submit explicitly). Best-effort: a get_sqe failure after - // one flush leaves the poll un-armed. Shared by the wakeup-eventfd and - // signal self-pipe multishot polls. + // register/init submit explicitly). Returns false when no SQE could be + // had after one flush, so a caller that has someone to report to does + // not mistake the submit of an empty queue for an armed poll. Shared by + // the wakeup-eventfd and signal self-pipe multishot polls. ::io_uring_sqe* sqe = ::io_uring_get_sqe(&ring_); if (!sqe) { @@ -798,9 +800,10 @@ io_uring_scheduler::prep_multishot_poll(int fd, void* data) noexcept sqe = ::io_uring_get_sqe(&ring_); } if (!sqe) - return; + return false; ::io_uring_prep_poll_multishot(sqe, fd, POLLIN); ::io_uring_sqe_set_data(sqe, data); + return true; } inline std::error_code @@ -817,7 +820,11 @@ io_uring_scheduler::register_signal_reader(int read_fd) lazy_init_ring(); lock_type lock(ring_mutex_); - prep_multishot_poll(read_fd, &signal_pipe_sentinel_); + // EAGAIN, not ENOBUFS: an exhausted submission queue reports the + // same retryable code wherever it is hit, and the next add() is + // the retry. + if (!prep_multishot_poll(read_fd, &signal_pipe_sentinel_)) + return make_err(EAGAIN); int rc = ::io_uring_submit(&ring_); if (rc < 0) return make_err(-rc); @@ -1310,8 +1317,10 @@ io_uring_scheduler::process_completions() // If multishot terminated (kernel dropped under memory // pressure or similar), re-arm. Each CQE except the last // sets IORING_CQE_F_MORE. + // Best-effort: nothing here has a caller to report to, and + // prep_multishot_poll already flushed once to make room. if ((cqe->flags & IORING_CQE_F_MORE) == 0) - prep_multishot_poll(wakeup_eventfd_, nullptr); + std::ignore = prep_multishot_poll(wakeup_eventfd_, nullptr); } else if (ud == &cancel_sentinel_) { @@ -1328,7 +1337,7 @@ io_uring_scheduler::process_completions() // io_uring_inflight_ (like the wakeup eventfd poll): its progress // does not gate DEFER_TASKRUN GETEVENTS. if ((cqe->flags & IORING_CQE_F_MORE) == 0) - prep_multishot_poll( + std::ignore = prep_multishot_poll( signal_pipe_read_fd_, &signal_pipe_sentinel_); bool expected = false; if (signal_drain_op_.queued_.compare_exchange_strong( @@ -1565,7 +1574,8 @@ io_uring_scheduler::drain_cqes_for(io_uring_op* target) noexcept // does. Never incremented, so never decremented. drain_wakeup_eventfd(); if ((cqe->flags & IORING_CQE_F_MORE) == 0) - prep_multishot_poll(wakeup_eventfd_, nullptr); + std::ignore = + prep_multishot_poll(wakeup_eventfd_, nullptr); } else if (ud == &signal_pipe_sentinel_) { @@ -1577,7 +1587,7 @@ io_uring_scheduler::drain_cqes_for(io_uring_op* target) noexcept // was armed via prep_multishot_poll, which never increments), // so it must NOT be decremented. if ((cqe->flags & IORING_CQE_F_MORE) == 0) - prep_multishot_poll( + std::ignore = prep_multishot_poll( signal_pipe_read_fd_, &signal_pipe_sentinel_); } else if (ud == &cancel_sentinel_) diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp index a6278cb3b..69a401f7a 100644 --- a/test/unit/fault/uring_faults.cpp +++ b/test/unit/fault/uring_faults.cpp @@ -110,6 +110,33 @@ struct uring_faults }); } + // A ring clamped to one SQE spends it on the wakeup poll when the + // ring is created, leaving none for the signal reader's multishot + // poll. The submit that follows succeeds because it has nothing + // left to submit, which is not the same as a reader watching the + // pipe. + void testSignalReaderSqFull() + { + in_child([]{ + io_context ioc(io_uring); + signal_set ss(ioc); + std::error_code ec; + bool fired = false; + { + // The ring is created lazily, so the clamp still + // catches it from inside add(). + fault_scope f(sys::uring_sqe_full, 0); + ec = ss.add(SIGUSR2); + fired = f.fired(); + } + // Not latched: with the SQ flushable again the next add() + // arms the reader. + return fired && + ec == std::errc::resource_unavailable_try_again && + !ss.add(SIGUSR2) && !ss.clear(); + }); + } + void testWaitFails() { { @@ -595,6 +622,7 @@ struct uring_faults testRingInitFails(); testRingInitLeaksNothing(); testSignalReaderSubmitFails(); + testSignalReaderSqFull(); testWaitFails(); testAcceptorDrainSubmitFails(); testSqFull(); From 89a3032370e3f2adf8e3b44fdb84413049a45ab6 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:17 +0200 Subject: [PATCH 06/34] fix(signal_set): report the real error when the self-pipe cannot be created open_signal_pipe returned a bool, so a pipe() or fcntl() failure reached the caller as io_error. It now returns the errno through make_err, so descriptor exhaustion reports too_many_files_open like the scheduler's own self-pipe does. --- .../detail/posix/posix_signal_service.hpp | 20 +++++++++++-------- test/unit/fault/posix_faults.cpp | 4 ++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/include/boost/corosio/native/detail/posix/posix_signal_service.hpp b/include/boost/corosio/native/detail/posix/posix_signal_service.hpp index d03afd606..1776248b3 100644 --- a/include/boost/corosio/native/detail/posix/posix_signal_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_signal_service.hpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -316,16 +317,18 @@ flags_compatible(signal_set::flags_t existing, signal_set::flags_t requested) // state->mutex before installing the first signal handler so write_fd is // valid by the time the handler can fire. Both ends are non-blocking and // close-on-exec (mirrors the reactor self-pipe setup in select_scheduler). -// Returns false and leaves the fds at -1 if creation fails. -inline bool +// Returns the failing call's errno and leaves the fds at -1 if creation +// fails: an exhausted descriptor table and a rejected fcntl are different +// problems to the caller of add(). +[[nodiscard]] inline std::error_code open_signal_pipe(signal_state* state) { if (state->read_fd >= 0) - return true; + return {}; int fds[2]; if (::pipe(fds) < 0) - return false; + return make_err(errno); for (int i = 0; i < 2; ++i) { @@ -333,15 +336,16 @@ open_signal_pipe(signal_state* state) if (fl == -1 || ::fcntl(fds[i], F_SETFL, fl | O_NONBLOCK) == -1 || ::fcntl(fds[i], F_SETFD, FD_CLOEXEC) == -1) { + auto ec = make_err(errno); ::close(fds[0]); ::close(fds[1]); - return false; + return ec; } } state->read_fd = fds[0]; state->write_fd = fds[1]; - return true; + return {}; } // C signal handler. Async-signal-safe: it touches only the single global @@ -555,8 +559,8 @@ posix_signal_service::add_signal( // this context race add() from different threads. { std::lock_guard state_lock(state->mutex); - if (!posix_signal_detail::open_signal_pipe(state)) - return make_error_code(std::errc::io_error); + if (auto ec = posix_signal_detail::open_signal_pipe(state)) + return ec; } { // Success-latched so a failed environmental registration diff --git a/test/unit/fault/posix_faults.cpp b/test/unit/fault/posix_faults.cpp index 145819e4d..ae87aed14 100644 --- a/test/unit/fault/posix_faults.cpp +++ b/test/unit/fault/posix_faults.cpp @@ -413,7 +413,7 @@ struct posix_common_faults signal_set ss(ioc); fault_scope f(sys::pipe, EMFILE); auto ec = ss.add(SIGUSR2); - return f.fired() && ec == std::errc::io_error; + return f.fired() && ec == std::errc::too_many_files_open; }); // 1..3 are F_GETFL, F_SETFL and F_SETFD on the read end. for(unsigned nth : {1u, 2u, 3u}) @@ -424,7 +424,7 @@ struct posix_common_faults int before = open_fds(); fault_scope f(sys::fcntl, EINVAL, nth); auto ec = ss.add(SIGUSR2); - return f.fired() && ec == std::errc::io_error && + return f.fired() && ec == std::errc::invalid_argument && open_fds() == before; }); } From edae8e102a29994fcacad7e26e05d66858972bf2 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:18 +0200 Subject: [PATCH 07/34] docs: correct the MSG_NOSIGNAL notes and state the run_task lock contract The kqueue and select traits claimed macOS lacks MSG_NOSIGNAL; Darwin defines it, and the reactor datagram paths already use it there. The flag is simply not universal across kqueue platforms, and the writev() and write() paths take no flags at all, which is why SO_NOSIGPIPE is set on every descriptor. reactor_scheduler::run_task now documents that it returns with the scheduler lock owned. --- .../native/detail/kqueue/kqueue_traits.hpp | 19 +++++++++++-------- .../native/detail/select/select_traits.hpp | 16 ++++++++++------ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp index 190f3baa9..c858bfff5 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp @@ -30,8 +30,9 @@ /* kqueue backend traits. Captures the platform-specific behavior of the BSD/macOS kqueue backend: - manual fcntl for O_NONBLOCK/FD_CLOEXEC, mandatory SO_NOSIGPIPE (macOS - lacks MSG_NOSIGNAL), writev() for writes, and accept()+fcntl for + manual fcntl for O_NONBLOCK/FD_CLOEXEC, mandatory SO_NOSIGPIPE + (MSG_NOSIGNAL is not universal across kqueue platforms, and writev() + takes no flags at all), writev() for writes, and accept()+fcntl for accepted connections. */ @@ -83,9 +84,9 @@ struct kqueue_traits return n; } - // Single-buffer fast path. macOS lacks MSG_NOSIGNAL; SIGPIPE is - // suppressed by the mandatory SO_NOSIGPIPE set in accept_policy - // and set_fd_options, so plain write() is safe here. + // Single-buffer fast path. write() carries no flag to suppress + // SIGPIPE; the mandatory SO_NOSIGPIPE set in accept_policy and + // set_fd_options does it per descriptor instead. static ssize_t write_one( int fd, void const* data, std::size_t size) noexcept { @@ -135,9 +136,11 @@ struct kqueue_traits } #ifndef BOOST_COROSIO_MRDOCS - // SO_NOSIGPIPE is mandatory on kqueue platforms (macOS lacks - // MSG_NOSIGNAL). Skipped under MRDOCS so the docs build can - // parse this header on Linux, where SO_NOSIGPIPE is absent. + // SO_NOSIGPIPE is mandatory on kqueue platforms: MSG_NOSIGNAL + // is not universal across them, and the writev() the write + // path uses takes no flags. Skipped under MRDOCS so the docs + // build can parse this header on Linux, where SO_NOSIGPIPE is + // absent. int one = 1; if (::setsockopt( new_fd, SOL_SOCKET, SO_NOSIGPIPE, diff --git a/include/boost/corosio/native/detail/select/select_traits.hpp b/include/boost/corosio/native/detail/select/select_traits.hpp index 33fca49af..f3e6354a5 100644 --- a/include/boost/corosio/native/detail/select/select_traits.hpp +++ b/include/boost/corosio/native/detail/select/select_traits.hpp @@ -158,9 +158,11 @@ struct select_traits } #ifdef SO_NOSIGPIPE - // SO_NOSIGPIPE is the only SIGPIPE guard on platforms that - // lack MSG_NOSIGNAL (macOS/BSD). Treat failure as fatal, - // matching the kqueue backend and Boost.Asio. + // MSG_NOSIGNAL is not universal across the platforms this + // portable backend covers, and the write() the fast path + // falls back to there takes no flag at all; SO_NOSIGPIPE is + // the per-descriptor guard that covers both. Treat failure + // as fatal, matching the kqueue backend. int one = 1; if (::setsockopt( new_fd, SOL_SOCKET, SO_NOSIGPIPE, @@ -199,9 +201,11 @@ struct select_traits return make_err(EMFILE); #ifdef SO_NOSIGPIPE - // SO_NOSIGPIPE is the only SIGPIPE guard on platforms that lack - // MSG_NOSIGNAL (macOS/BSD). Treat failure as fatal, matching the - // kqueue backend and Boost.Asio. Caller closes fd on error. + // MSG_NOSIGNAL is not universal across the platforms this + // portable backend covers, and the write() the fast path falls + // back to there takes no flag at all; SO_NOSIGPIPE is the + // per-descriptor guard that covers both. Treat failure as fatal, + // matching the kqueue backend. Caller closes fd on error. { int one = 1; if (::setsockopt( From 8a8eb0ecf3f4782f98c8072789ac659669e52b89 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:19 +0200 Subject: [PATCH 08/34] fix(iocp): release the completion port when a nested service throws The scheduler constructor creates the completion port and then registers the timer, resolver and wait-reactor services, any of which can throw; the port handle was leaked on that path. The constructor now closes the port before rethrowing. Because the context has no service unregistration, a service registered before the throw outlives the scheduler; that is safe because a scheduler that never finished constructing handed out no timer, resolver or file, so every shutdown walks an empty list. The reason is recorded at the catch. --- .../native/detail/iocp/win_scheduler.hpp | 24 ++++++++++++------- test/unit/fault/fault_test_utils.hpp | 15 +++++++++--- test/unit/fault/iocp_faults.cpp | 13 ++++++---- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp index 55e148417..0d212716e 100644 --- a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp +++ b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp @@ -727,14 +727,22 @@ inline win_scheduler::win_scheduler( if (iocp_ == nullptr) detail::throw_system_error(make_err(::GetLastError())); - // Create timer wakeup mechanism (tries NT native, falls back to thread) - timers_ = make_win_timers(iocp_, &dispatch_required_); - - // Connect timer service to scheduler - set_timer_service(&get_timer_service(ctx, *this)); - - // Initialize resolver service - ctx.make_service(*this); + try + { + timers_ = make_win_timers(iocp_, &dispatch_required_); + set_timer_service(&get_timer_service(ctx, *this)); + ctx.make_service(*this); + } + catch (...) + { + // ~win_scheduler never runs for a constructor that throws, and + // the port is a raw handle nothing else owns. The timer thread + // is stopped first because it posts to that port. + timers_.reset(); + ::CloseHandle(iocp_); + iocp_ = nullptr; + throw; + } } inline void diff --git a/test/unit/fault/fault_test_utils.hpp b/test/unit/fault/fault_test_utils.hpp index 145385bcc..e36bf2342 100644 --- a/test/unit/fault/fault_test_utils.hpp +++ b/test/unit/fault/fault_test_utils.hpp @@ -79,9 +79,8 @@ void in_child(F&& body) // enough repetitions that a per-call leak, which grows the count once // per call, separates from that ambient noise. template -void expect_no_handle_leak(F&& fn) +void expect_no_handle_leak(F&& fn, int reps, int max_growth) { - constexpr int reps = 8; fn(); int const before = open_fds(); // open_fds() answers -1 when the count cannot be read, which would @@ -92,7 +91,17 @@ void expect_no_handle_leak(F&& fn) int const after = open_fds(); // A -1 here would satisfy the growth comparison on its own. BOOST_TEST(after >= 0); - BOOST_TEST(after - before < reps); + BOOST_TEST(after - before < max_growth); +} + +// The default shape: eight repetitions, and a leak of one handle per +// call lands exactly on the threshold. A call site whose leak is +// exactly one handle should ask for more repetitions than it allows +// growth, so the two are not decided by a single ambient handle. +template +void expect_no_handle_leak(F&& fn) +{ + expect_no_handle_leak(fn, 8, 8); } // The Win32 and Winsock codes the library hands back unchanged compare diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index c063324e5..a331218d0 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -101,15 +101,18 @@ struct iocp_faults { void testSchedulerConstructFails() { - { - // Winsock is started once per process and released when - // the last service goes, so this only fires while no - // io_context is alive (win_wsa_init.hpp:57-67). + // Winsock is started once per process and released when the + // last service goes, so this only fires while no io_context is + // alive (win_wsa_init.hpp:57-67). The resolver service that + // starts it is built inside the scheduler's constructor, after + // the completion port: the port is the scheduler's to release + // on the way out (win_scheduler.hpp:713-748). + expect_no_handle_leak([]{ fault_scope f(sys::WSAStartup, WSAEAFNOSUPPORT); expect_system_error([]{ io_context ioc(iocp); }, std::errc::address_family_not_supported); BOOST_TEST(f.fired()); - } + }); { // The scheduler's own port: CreateIoCompletionPort with // INVALID_HANDLE_VALUE (win_scheduler.hpp:722-728). From f249f49bec59c8020f40cd13bc31251ba4c9af37 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:20 +0200 Subject: [PATCH 09/34] fix(local_connect_pair): give up the accept when the worker cannot connect On Windows connect_pair builds the pair with a listener and a worker thread that connects to it. When the worker's socket creation or connect failed, the caller blocked forever in accept(). The listener is now non-blocking and the caller polls it while the worker publishes its verdict on every exit path, returning the worker's error instead of waiting for a connection that will never come. --- src/corosio/src/local_connect_pair.cpp | 114 ++++++++++++++++++++----- test/unit/fault/win_faults.cpp | 95 ++++++++++++++++++++- 2 files changed, 183 insertions(+), 26 deletions(-) diff --git a/src/corosio/src/local_connect_pair.cpp b/src/corosio/src/local_connect_pair.cpp index 1a7a631d3..f156c0a85 100644 --- a/src/corosio/src/local_connect_pair.cpp +++ b/src/corosio/src/local_connect_pair.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -178,40 +179,96 @@ make_pair_sockets(SOCKET& a_sock, SOCKET& b_sock) noexcept return ec; } - SOCKET worker_sock = INVALID_SOCKET; - std::error_code worker_ec; + // A worker that fails before connecting produces no connection at + // all, so the accept below must be able to give up. Poll the + // listener instead of blocking in accept() forever. + u_long non_blocking = 1; + if (::ioctlsocket(listen_sock, FIONBIO, &non_blocking) == SOCKET_ERROR) + { + auto ec = detail::make_err(::WSAGetLastError()); + ::closesocket(listen_sock); + remove_pair_path(dir, path); + return ec; + } + SOCKET worker_sock = INVALID_SOCKET; + std::error_code worker_ec; + std::atomic worker_done{false}; + + // One exit, so worker_done is published on every path: the accept + // below waits on it, and a path that skipped it would hang. std::thread worker([&] { worker_sock = ::WSASocketW( AF_UNIX, SOCK_STREAM, 0, nullptr, 0, WSA_FLAG_OVERLAPPED); if (worker_sock == INVALID_SOCKET) { worker_ec = detail::make_err(::WSAGetLastError()); - return; } - - detail::un_sa_t caddr{}; - caddr.sun_family = AF_UNIX; - std::memcpy( - caddr.sun_path, path.c_str(), - (std::min)(path.size(), sizeof(caddr.sun_path) - 1)); - int caddr_len = static_cast( - offsetof(detail::un_sa_t, sun_path) + path.size() + 1); - - if (::connect( - worker_sock, reinterpret_cast(&caddr), caddr_len) - == SOCKET_ERROR) + else { - worker_ec = detail::make_err(::WSAGetLastError()); - ::closesocket(worker_sock); - worker_sock = INVALID_SOCKET; + detail::un_sa_t caddr{}; + caddr.sun_family = AF_UNIX; + std::memcpy( + caddr.sun_path, path.c_str(), + (std::min)(path.size(), sizeof(caddr.sun_path) - 1)); + int caddr_len = static_cast( + offsetof(detail::un_sa_t, sun_path) + path.size() + 1); + + if (::connect( + worker_sock, + reinterpret_cast(&caddr), caddr_len) + == SOCKET_ERROR) + { + worker_ec = detail::make_err(::WSAGetLastError()); + ::closesocket(worker_sock); + worker_sock = INVALID_SOCKET; + } } + // Released last so a reader that sees it also sees worker_ec. + worker_done.store(true, std::memory_order_release); }); - SOCKET accept_sock = ::accept(listen_sock, nullptr, nullptr); + SOCKET accept_sock = INVALID_SOCKET; std::error_code accept_ec; - if (accept_sock == INVALID_SOCKET) - accept_ec = detail::make_err(::WSAGetLastError()); + for (;;) + { + // A worker that succeeded has left a connection in the + // backlog, so only a failed one means nothing is coming. + if (worker_done.load(std::memory_order_acquire) && worker_ec) + break; + + WSAPOLLFD pfd{listen_sock, POLLRDNORM, 0}; + int const n = ::WSAPoll(&pfd, 1, 100); + if (n == SOCKET_ERROR) + { + accept_ec = detail::make_err(::WSAGetLastError()); + break; + } + if (n == 0) + continue; + + // Readiness that is not "a connection is waiting" is an error + // condition on the listener; accepting on it would spin. The + // condition carries no retrievable code, so this one is + // corosio's own and has to compare equal on every toolchain. + if ((pfd.revents & POLLRDNORM) == 0) + { + accept_ec = + std::make_error_code(std::errc::connection_aborted); + break; + } + + accept_sock = ::accept(listen_sock, nullptr, nullptr); + if (accept_sock != INVALID_SOCKET) + break; + DWORD const err = ::WSAGetLastError(); + // A readiness report with nothing left to accept: keep + // waiting for the worker's connection. + if (err == WSAEWOULDBLOCK) + continue; + accept_ec = detail::make_err(err); + break; + } worker.join(); @@ -226,10 +283,23 @@ make_pair_sockets(SOCKET& a_sock, SOCKET& b_sock) noexcept } if (worker_ec) { - ::closesocket(accept_sock); + if (accept_sock != INVALID_SOCKET) + ::closesocket(accept_sock); return worker_ec; } + // accept() inherits the listener's non-blocking mode; the rest of + // the IOCP backend hands out blocking sockets and drives them + // through overlapped I/O. + non_blocking = 0; + if (::ioctlsocket(accept_sock, FIONBIO, &non_blocking) == SOCKET_ERROR) + { + auto ec = detail::make_err(::WSAGetLastError()); + ::closesocket(accept_sock); + ::closesocket(worker_sock); + return ec; + } + a_sock = accept_sock; b_sock = worker_sock; return {}; diff --git a/test/unit/fault/win_faults.cpp b/test/unit/fault/win_faults.cpp index a10dc2bb7..3d77617dc 100644 --- a/test/unit/fault/win_faults.cpp +++ b/test/unit/fault/win_faults.cpp @@ -23,10 +23,15 @@ #include #include +#include #include +#include +#include #include +#include #include #include +#include #include #if BOOST_COROSIO_HAS_IOCP @@ -41,6 +46,30 @@ void remove_file(std::string const& path) std::ignore = std::filesystem::remove(std::filesystem::path(path), ec); } +// Run connect_pair with a deadline. A rendezvous that cannot finish +// would otherwise stall until the CI job runs out of time, which reads +// as an infrastructure failure rather than as this test; leaving the +// stuck thread behind and carrying on is not an option either, since +// it holds references into the caller's frame. +std::error_code +connect_pair_bounded(local_stream_socket& a, local_stream_socket& b) +{ + std::error_code ec; + std::promise done; + auto ready = done.get_future(); + std::thread t([&]{ ec = connect_pair(a, b); done.set_value(); }); + if(ready.wait_for(std::chrono::seconds(5)) != std::future_status::ready) + { + BOOST_TEST(false); + std::fprintf(stderr, + "fault harness: connect_pair did not return within 5s\n"); + std::fflush(stderr); + std::_Exit(1); + } + t.join(); + return ec; +} + } // namespace /* Faults on the Windows entry points that are not the IOCP backend's @@ -366,9 +395,12 @@ struct win_common_faults void testConnectPairFails() { io_context ioc(iocp); - // The pair is built by hand out of a listening AF_UNIX socket, - // a worker that connects and a blocking accept here; each of - // those four calls has its own failure path. + // The pair is built by hand: a listening AF_UNIX socket put + // into non-blocking mode, a worker thread that connects, a + // polled accept here, and the accepted socket put back into + // blocking mode. Every one of those calls is faulted below, + // and each has a cleanup path of its own + // (local_connect_pair.cpp, make_pair_sockets). { local_stream_socket a(ioc), b(ioc); fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); @@ -403,6 +435,61 @@ struct win_common_faults BOOST_TEST(ec == std::errc::not_a_socket); BOOST_TEST(!a.is_open() && !b.is_open()); } + { + // The listener cannot be polled while it blocks, so this + // fails before the worker is even started. + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::ioctlsocket, WSAENOBUFS); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAENOBUFS)); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + // The two worker-side failures: neither ever produces a + // connection, so the accept has to give up on its own and + // hand back what the worker saw. The arms are process-wide + // because the call they fail is on the worker thread, and the + // socket arm is the second WSASocketW because the listener is + // created first. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::WSASocketW, WSAEMFILE, 2u, any_thread); + auto ec = connect_pair_bounded(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAEMFILE)); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::connect, WSAENETDOWN, 1u, any_thread); + auto ec = connect_pair_bounded(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAENETDOWN)); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + // A poll that fails abandons the accept while the worker is + // still connecting, so the socket the worker hands back has + // to be closed here. Process-wide, since the arm has to + // survive the hop onto the thread the deadline runs it on. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::WSAPoll, WSAEINTR, 1u, any_thread); + auto ec = connect_pair_bounded(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAEINTR)); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); + // Restoring the accepted socket's blocking mode is the last + // thing that can fail, and the only failure with a complete + // pair in hand: both ends are the library's to close. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::ioctlsocket, WSAEINVAL, 2u); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == win_err(WSAEINVAL)); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); // Adoption of the first descriptor fails; both are the // library's to close. expect_no_handle_leak([&]{ @@ -415,7 +502,7 @@ struct win_common_faults }); // Unfaulted, a pair still forms. local_stream_socket a(ioc), b(ioc); - BOOST_TEST(!connect_pair(a, b)); + BOOST_TEST(!connect_pair_bounded(a, b)); } void testAvailableThrows() From 2fc8df086f0bf259d13bd99a0769aaf01ca44a42 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:21 +0200 Subject: [PATCH 10/34] fix(iocp): create the wait reactor's wakeup pair when the io_context is constructed The wait reactor was built on the first wait(), and a failure to create its loopback wakeup pair was silent: the poll thread started on an invalid socket, every write and error wait parked forever, and run() never returned. The pair is now created when the scheduler is constructed, so the failure throws from the io_context constructor like every other backend's infrastructure, while the poll thread still starts on the first wait. The reactor owns its own Winsock reference, a zero last-error can no longer disguise a failure, and a cancel for a wait that was never parked is ignored rather than answered against the next wait; a wait registered after stop() completes as cancelled. The thread is still the first wait's cost, but a system that refuses one now completes that wait with resource_unavailable_try_again and leaves the reactor to try again on the next, rather than throwing out of an async initiator. --- doc/error-handling-rulebook.md | 23 ++- .../win_local_stream_acceptor_service.hpp | 6 +- .../detail/iocp/win_local_stream_service.hpp | 6 +- .../native/detail/iocp/win_overlapped_op.hpp | 7 +- .../native/detail/iocp/win_scheduler.hpp | 78 ++++---- .../detail/iocp/win_tcp_acceptor_service.hpp | 20 +- .../native/detail/iocp/win_udp_service.hpp | 6 +- .../native/detail/iocp/win_wait_reactor.hpp | 173 +++++++++++++--- .../boost/corosio/native/detail/make_err.hpp | 12 +- test/unit/fault/iocp_faults.cpp | 184 +++++++++++------- 10 files changed, 348 insertions(+), 167 deletions(-) diff --git a/doc/error-handling-rulebook.md b/doc/error-handling-rulebook.md index 9c7db31b9..f513702c7 100644 --- a/doc/error-handling-rulebook.md +++ b/doc/error-handling-rulebook.md @@ -62,6 +62,14 @@ as `std::system_error` carrying the code the piecewise spelling returns); and root setup for which no code-returning spelling can exist (`io_context` backend creation, allocation). +Root setup is everything the backend needs — completion port, ring, +wakeup channel, thread pool — and all of it is built during +construction, so a system that refuses any of it throws from the +constructor instead of from the first operation, and the failed +construction leaves nothing open. An initiator may then assume that +infrastructure exists, which is what makes "initiators never throw" +reachable at all. + ## 3. The Classification Test Ask: **can the caller reliably prevent the failure by checking state @@ -159,14 +167,16 @@ second channel: `no_such_device_or_address` (`corosio::connect` with no viable candidate), `resource_unavailable_try_again` (io_uring submission queue - exhausted, for a submitted op and for the signal reader alike). + exhausted, for a submitted op and for the signal reader alike; and a + polling thread the system would not start). - Portable comparison comes from **normalizing at the boundary**: the Windows `make_err` maps the contracted WSA/Win32 codes to generic-category `errc` values (`WSAEOPNOTSUPP`, `WSAENOTSOCK`, `WSAEAFNOSUPPORT`, `WSAEPROTOTYPE`, `WSAEADDRINUSE`, - `WSAEADDRNOTAVAIL`, `ERROR_NEGATIVE_SEEK`; `iocp_make_err` adds the - async condition set and `WSAEBADF`/`ERROR_INVALID_HANDLE`). On - POSIX, raw errno satisfies `errc` comparison with one exception: + `WSAEADDRNOTAVAIL`, `ERROR_NEGATIVE_SEEK`, `ERROR_MAX_THRDS_REACHED`; + `iocp_make_err` adds the async condition set and + `WSAEBADF`/`ERROR_INVALID_HANDLE`). On POSIX, raw errno satisfies + `errc` comparison with one exception: `make_err` normalizes `ENOTSUP` so platforms where it differs from `EOPNOTSUPP` still compare equal to `errc::operation_not_supported`. @@ -177,6 +187,11 @@ second channel: AF_INET sockets; Darwin's `getsockopt(TCP_NODELAY)` on AF_UNIX); Windows reports `not_a_socket` where POSIX validation reports `EBADF` for garbage (non-sentinel) handles. +- A failing call that leaves a zero last error must not become an + empty `error_code`: read the last error before anything that can + clobber it, and substitute rather than report success. Prefer a + contracted condition to a plausible-looking raw platform value, + which is indistinguishable from a code the provider really gave. - Conditions the standard cannot spell come from capy: `capy::cond::eof`, `capy::cond::canceled` (a stop token, not `errc::operation_canceled`), `capy::cond::timeout` (our deadline, 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 66af42719..179cb1ad0 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 @@ -113,7 +113,7 @@ local_stream_acceptor_wait_op::do_cancel_impl(overlapped_op* base) noexcept if (op->acceptor_ptr) { op->acceptor_ptr->socket_service().scheduler() - .cancel_wait_if_constructed(op); + .cancel_wait(op); } } @@ -291,7 +291,7 @@ win_local_stream_acceptor_internal::cancel() noexcept ::CancelIoEx(reinterpret_cast(socket_), nullptr); acc_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); } inline std::coroutine_handle<> @@ -345,7 +345,7 @@ win_local_stream_acceptor_internal::close_socket() noexcept // to connection_aborted by iocp_make_err (see win_tcp_socket close_socket). acc_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { 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 b8725e23f..84b0cd5cb 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 @@ -218,7 +218,7 @@ local_stream_wait_op::do_cancel_impl(overlapped_op* base) noexcept ::CancelIoEx( reinterpret_cast(op->internal.native_handle()), op); } - op->internal.svc_.scheduler().cancel_wait_if_constructed(op); + op->internal.svc_.scheduler().cancel_wait(op); } // ============================================================ @@ -679,7 +679,7 @@ win_local_stream_socket_internal::cancel() noexcept rd_.request_cancel(); wr_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); } inline void @@ -692,7 +692,7 @@ win_local_stream_socket_internal::close_socket() noexcept rd_.request_cancel(); wr_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { 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 75d80331e..86cc33d8d 100644 --- a/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp +++ b/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp @@ -153,7 +153,12 @@ struct overlapped_op bytes_transferred = 0; empty_buffer = false; is_read = false; - cancelled.store(false, std::memory_order_relaxed); + // Release, not relaxed: the wait reactor decides whether a + // queued cancel request is stale by loading this flag, so the + // clear has to be ordered against the fields written above it + // rather than against whichever lock the caller happens to + // take next. + cancelled.store(false, std::memory_order_release); } // coro_op::request_cancel() (set the cancelled flag) is inherited diff --git a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp index 0d212716e..bb5b1fddb 100644 --- a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp +++ b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp @@ -138,31 +138,21 @@ class BOOST_COROSIO_DECL win_scheduler final mutable op_queue completed_ops_; std::unique_ptr timers_; std::unique_ptr wait_reactor_; - std::once_flag wait_reactor_once_; - std::atomic wait_reactor_ready_{false}; BOOST_COROSIO_MSVC_WARNING_POP public: - /** Auxiliary select-based reactor for IOCP wait operations. + /** Return the auxiliary select-based reactor for wait operations. - Lazily created on first access; lives for the lifetime of the - scheduler and is stopped+joined in ~win_scheduler. Used by - socket and acceptor wait() implementations whose readiness - cannot be expressed natively in IOCP (datagram-read, - acceptor-read, error-wait). + Built with the scheduler and stopped+joined in ~win_scheduler, + so it is always there to hand a wait to. Used by socket and + acceptor wait() implementations whose readiness cannot be + expressed natively in IOCP (datagram-read, acceptor-read, + error-wait). */ win_wait_reactor& wait_reactor(); - /** Cancel a parked wait op only if the reactor exists. - - Safe to call from any thread. If no wait op has ever been - registered, the reactor was never constructed, so there is - nothing to cancel and we avoid spinning up a thread + wakeup - socketpair on the cancel path. Acquire/release pairs with the - store in wait_reactor() so reads see a fully-constructed - reactor when the flag is true. - */ - void cancel_wait_if_constructed(overlapped_op* op) noexcept; + /// Cancel a parked wait op. Safe to call from any thread. + void cancel_wait(overlapped_op* op) noexcept; }; /* @@ -217,10 +207,11 @@ struct thread_context_guard } // namespace iocp -// The constructor, ~win_scheduler() and shutdown() are defined at the -// bottom of this header so the unique_ptr's deleter -// and wait_reactor_->stop() see the type complete. The constructor -// needs it too: its unwind path destroys wait_reactor_. +// The constructor, ~win_scheduler(), shutdown(), wait_reactor() and +// cancel_wait() are defined at the bottom of this header so the +// unique_ptr's deleter, its operator* and +// wait_reactor_->stop() see the type complete. The constructor needs +// it to build the reactor, and its unwind path to destroy it. inline void win_scheduler::post(std::coroutine_handle<> h) const @@ -700,8 +691,8 @@ win_scheduler::update_timeout() // Defer including the auxiliary wait reactor until the scheduler is // fully defined, since the reactor's inline methods call back into // win_scheduler. This also gives the ctor, dtor and wait_reactor() -// below a complete win_wait_reactor type for unique_ptr destruction -// and lazy construction. +// below a complete win_wait_reactor type: the constructor builds it, +// the destructor and unique_ptr's deleter tear it down. // // The macro lets win_wait_reactor.hpp diagnose direct inclusion // (which would land it here with win_scheduler still incomplete). @@ -732,12 +723,32 @@ inline win_scheduler::win_scheduler( timers_ = make_win_timers(iocp_, &dispatch_required_); set_timer_service(&get_timer_service(ctx, *this)); ctx.make_service(*this); + + // A scheduler whose wait reactor could not be built would + // answer every wait with a parked op, so it refuses to exist + // instead. Last, so the catch below is the whole cleanup: + // the reactor holds its own Winsock reference and needs no + // help from the order. + wait_reactor_ = std::make_unique(*this); } catch (...) { // ~win_scheduler never runs for a constructor that throws, and // the port is a raw handle nothing else owns. The timer thread // is stopped first because it posts to that port. + // + // The services registered above are not unregistered here, and + // cannot be: the context owns them and offers no way to take + // one back. Each holds a scheduler reference this unwind is + // about to invalidate, and each survives to be shut down and + // destroyed with the context. What makes that safe is that + // none of them touches its scheduler while it holds nothing: + // a scheduler that never finished constructing handed out no + // timer, resolver or file, so every one of those shutdowns + // walks an empty list. Registering anything here that would + // reach back into the scheduler on an empty shutdown breaks + // that, and the fix would have to be a rollback in the + // context, not an ordering trick here. timers_.reset(); ::CloseHandle(iocp_); iocp_ = nullptr; @@ -763,8 +774,7 @@ win_scheduler::shutdown() // Same problem for the auxiliary wait reactor: ops parked in it // owe completion packets. Stop the reactor early so its loop // posts them as cancelled and the pending count can reach zero. - if (wait_reactor_ready_.load(std::memory_order_acquire)) - wait_reactor_->stop(); + wait_reactor_->stop(); // Reap every packet still owed to the port before the services // free the op memory those packets reference. Work-guard credits, @@ -850,8 +860,7 @@ win_scheduler::shutdown() inline win_scheduler::~win_scheduler() { - if (wait_reactor_) - wait_reactor_->stop(); + wait_reactor_->stop(); wait_reactor_.reset(); if (iocp_ != nullptr) @@ -861,22 +870,13 @@ inline win_scheduler::~win_scheduler() inline win_wait_reactor& win_scheduler::wait_reactor() { - // Lazy thread-safe init: multiple IOCP workers may race the first - // wait() call. wait_reactor_ready_ is set with release ordering - // after construction so cancel_wait_if_constructed can safely - // observe the reactor without forcing construction itself. - std::call_once(wait_reactor_once_, [this] { - wait_reactor_ = std::make_unique(*this); - wait_reactor_ready_.store(true, std::memory_order_release); - }); return *wait_reactor_; } inline void -win_scheduler::cancel_wait_if_constructed(overlapped_op* op) noexcept +win_scheduler::cancel_wait(overlapped_op* op) noexcept { - if (wait_reactor_ready_.load(std::memory_order_acquire)) - wait_reactor_->cancel_wait(op); + wait_reactor_->cancel_wait(op); } } // namespace boost::corosio::detail 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 80c52f86d..a490328cf 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 @@ -169,10 +169,9 @@ wait_op::do_cancel_impl(overlapped_op* base) noexcept reinterpret_cast(op->internal.native_handle()), op); } // wait_type::error parks the op in the auxiliary select reactor; - // wake it so the reactor can post a cancelled completion. No-op - // if the reactor was never constructed (e.g. zero-byte WSARecv - // path was the only thing this socket ever did). - op->internal.svc_.scheduler().cancel_wait_if_constructed(op); + // wake it so the reactor can post a cancelled completion. A cancel + // for an op the reactor never registered finds nothing and returns. + op->internal.svc_.scheduler().cancel_wait(op); } inline void @@ -197,7 +196,7 @@ acceptor_wait_op::do_cancel_impl(overlapped_op* base) noexcept if (op->acceptor_ptr) { op->acceptor_ptr->socket_service().scheduler() - .cancel_wait_if_constructed(op); + .cancel_wait(op); } } @@ -791,9 +790,8 @@ win_tcp_socket_internal::cancel() noexcept wt_.request_cancel(); // CancelIoEx covers overlapped I/O on the socket but cannot reach // a wait op parked in the auxiliary reactor (no overlapped is - // outstanding). Route through the reactor explicitly. Safe no-op - // if the reactor was never constructed. - svc_.scheduler().cancel_wait_if_constructed(&wt_); + // outstanding). Route through the reactor explicitly. + svc_.scheduler().cancel_wait(&wt_); } inline void @@ -813,7 +811,7 @@ win_tcp_socket_internal::close_socket() noexcept // otherwise the reactor would keep polling a dangling fd (and on a Winsock // SOCKET-id reuse the wrong fd could be polled briefly). wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { @@ -1589,7 +1587,7 @@ win_tcp_acceptor_internal::cancel() noexcept acc_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); } inline void @@ -1601,7 +1599,7 @@ win_tcp_acceptor_internal::close_socket() noexcept acc_.request_cancel(); // Tear down any aux-reactor-parked wait op first. wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { 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 b26028862..9ac93efff 100644 --- a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp @@ -266,7 +266,7 @@ udp_wait_op::do_cancel_impl(overlapped_op* base) noexcept ::CancelIoEx( reinterpret_cast(op->internal.native_handle()), op); } - op->internal.svc_.scheduler().cancel_wait_if_constructed(op); + op->internal.svc_.scheduler().cancel_wait(op); } // Connected-mode completion handlers @@ -746,7 +746,7 @@ win_udp_socket_internal::cancel() noexcept send_wr_.request_cancel(); recv_rd_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); } inline void @@ -761,7 +761,7 @@ win_udp_socket_internal::close_socket() noexcept send_wr_.request_cancel(); recv_rd_.request_cancel(); wt_.request_cancel(); - svc_.scheduler().cancel_wait_if_constructed(&wt_); + svc_.scheduler().cancel_wait(&wt_); if (socket_ != INVALID_SOCKET) { 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 82418e8d4..412933aba 100644 --- a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp @@ -28,6 +28,7 @@ instead of including this header directly." #include #include #include +#include #include @@ -68,13 +69,32 @@ namespace boost::corosio::detail { op from the table and posts a completion; invoke_handler sees op.cancelled==true and yields capy::cond::canceled. + The constructor builds the wakeup channel and throws if it cannot: + a reactor that cannot be woken can never report readiness, so + there is no reactor worth handing back. The polling thread is a + separate cost, paid by the first register_wait, so a context that + never waits never carries one. + Thread-safe: register_wait, cancel_wait, and stop may be called from any thread. */ -class win_wait_reactor +class win_wait_reactor : private win_wsa_init { public: + /** Construct the reactor and its wakeup channel. + + @par Exception Safety + Strong guarantee. A channel that cannot be formed leaves no + socket open. + + @param sched The scheduler synthetic completions are posted to. + + @throws std::system_error If Winsock could not be started or + the wakeup socket pair could not be built. + */ explicit win_wait_reactor(win_scheduler& sched); + + /// Stop the reactor thread and close the wakeup channel. ~win_wait_reactor(); win_wait_reactor(win_wait_reactor const&) = delete; @@ -98,10 +118,19 @@ class win_wait_reactor }; void run(); + DWORD queue_register(entry const& e); void wake_self() noexcept; - void make_wakeup_pair(); + DWORD make_wakeup_pair() noexcept; void close_wakeup_pair() noexcept; + // A failed call that left a zero last error would answer "no + // error" and put the reactor straight back on the silent path. + static DWORD wakeup_error() noexcept + { + DWORD const err = ::WSAGetLastError(); + return err != 0 ? err : static_cast(WSAEINVAL); + } + static SHORT events_for_wait(wait_type w) noexcept { switch (w) @@ -128,9 +157,12 @@ class win_wait_reactor win_scheduler& sched_; + // Built by the constructor and closed by the destructor, so every + // other member function can assume a usable channel. SOCKET wakeup_read_ = INVALID_SOCKET; SOCKET wakeup_write_ = INVALID_SOCKET; + // Also guards thread_ against a start racing the stop that joins it. std::mutex mutex_; std::vector pending_register_; std::vector pending_cancel_; @@ -145,8 +177,17 @@ class win_wait_reactor inline win_wait_reactor::win_wait_reactor(win_scheduler& sched) : sched_(sched) { - make_wakeup_pair(); - thread_ = std::thread([this] { run(); }); + // The win_wsa_init base is what makes the sockets below legal, and + // holding the reference rather than borrowing someone else's is + // what keeps them legal to the end: a base is constructed before + // this body and released after ~win_wait_reactor has closed the + // pair, so WSACleanup can never land between the two. + // + // A reactor that cannot be woken would park every op it is handed + // forever, so it refuses to exist rather than being handed out + // broken. The polling thread waits for the first register_wait. + if (DWORD const err = make_wakeup_pair(); err != 0) + detail::throw_system_error(make_err(err), "win_wait_reactor"); } inline win_wait_reactor::~win_wait_reactor() @@ -155,15 +196,18 @@ inline win_wait_reactor::~win_wait_reactor() close_wakeup_pair(); } -inline void -win_wait_reactor::make_wakeup_pair() +inline DWORD +win_wait_reactor::make_wakeup_pair() noexcept { // Build a pair of connected loopback sockets to use as a wakeup // channel. Winsock has no socketpair(2), so we listen on // 127.0.0.1:0, connect a peer, then accept it. + // + // Every failure path reads the last error before closing + // anything: closesocket() overwrites it. SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listener == INVALID_SOCKET) - return; + return wakeup_error(); sockaddr_in addr{}; addr.sin_family = AF_INET; @@ -177,42 +221,52 @@ win_wait_reactor::make_wakeup_pair() ::getsockname(listener, reinterpret_cast(&addr), &len) == SOCKET_ERROR) { + DWORD const err = wakeup_error(); ::closesocket(listener); - return; + return err; } wakeup_write_ = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (wakeup_write_ == INVALID_SOCKET) { + DWORD const err = wakeup_error(); ::closesocket(listener); - return; + return err; } if (::connect( wakeup_write_, reinterpret_cast(&addr), len) == SOCKET_ERROR) { + DWORD const err = wakeup_error(); ::closesocket(wakeup_write_); wakeup_write_ = INVALID_SOCKET; ::closesocket(listener); - return; + return err; } wakeup_read_ = ::accept(listener, nullptr, nullptr); - ::closesocket(listener); - if (wakeup_read_ == INVALID_SOCKET) { + DWORD const err = wakeup_error(); + ::closesocket(listener); ::closesocket(wakeup_write_); wakeup_write_ = INVALID_SOCKET; - return; + return err; } + ::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. u_long non_blocking = 1; - ::ioctlsocket(wakeup_read_, FIONBIO, &non_blocking); + if (::ioctlsocket(wakeup_read_, FIONBIO, &non_blocking) == SOCKET_ERROR) + { + DWORD const err = wakeup_error(); + close_wakeup_pair(); + return err; + } + return 0; } inline void @@ -238,19 +292,17 @@ win_wait_reactor::wake_self() noexcept if (!wake_pending_.compare_exchange_strong( expected, true, std::memory_order_acq_rel)) return; - if (wakeup_write_ != INVALID_SOCKET) + + char b = 0; + if (::send(wakeup_write_, &b, 1, 0) == SOCKET_ERROR) { - char b = 0; - if (::send(wakeup_write_, &b, 1, 0) == SOCKET_ERROR) - { - // The self-pipe byte is the only thing that wakes the reactor - // thread from an indefinite poll(); a coalesced lost wakeup - // would leave wake_pending_ stuck true and hang the op. - // wakeup_write_ is a blocking socket, so a 1-byte send can - // only fail on a hard error -- fatal, mirroring a failed - // PostQueuedCompletionStatus in win_scheduler. - detail::throw_system_error(make_err(::WSAGetLastError())); - } + // The self-pipe byte is the only thing that wakes the reactor + // thread from an indefinite poll(); a coalesced lost wakeup + // would leave wake_pending_ stuck true and hang the op. + // wakeup_write_ is a blocking socket, so a 1-byte send can + // only fail on a hard error -- fatal, mirroring a failed + // PostQueuedCompletionStatus in win_scheduler. + detail::throw_system_error(make_err(::WSAGetLastError())); } } @@ -268,13 +320,53 @@ win_wait_reactor::register_wait( sched_.on_completion(op, 0, 0); return; } + + if (DWORD const err = queue_register(entry{fd, w, op}); err != 0) { - std::lock_guard lock(mutex_); - pending_register_.push_back(entry{fd, w, op}); + // The reactor is stopped, so nothing would ever drain a parked + // op. Report the abort its own shutdown drain gives the ops it + // was still holding. + sched_.on_completion(op, err, 0); + return; } wake_self(); } +inline DWORD +win_wait_reactor::queue_register(entry const& e) +{ + std::lock_guard lock(mutex_); + // stop() sets the flag before it takes the thread out from under + // this lock, and never joins again. Checking the flag and queueing + // in one critical section is what keeps both halves honest: a + // thread started after that join would be destroyed still joinable, + // which ends the process, and an op queued after it would have no + // drainer. Queueing under the flag instead leaves the op for the + // drain run() performs on its way out. + if (stop_.load(std::memory_order_acquire)) + return ERROR_OPERATION_ABORTED; + + // A polling thread costs a thread per context, and a context that + // never waits never pays for one; the first wait is what starts it. + // A system that will not give one refuses this wait alone: nothing + // has been queued yet, so the reactor is left exactly as it was and + // the next register_wait asks again. + if (!thread_.joinable()) + { + try + { + thread_ = std::thread([this] { run(); }); + } + catch (...) + { + return ERROR_MAX_THRDS_REACHED; + } + } + + pending_register_.push_back(e); + return 0; +} + inline void win_wait_reactor::cancel_wait(overlapped_op* op) { @@ -291,8 +383,17 @@ win_wait_reactor::stop() if (stop_.exchange(true, std::memory_order_acq_rel)) return; wake_self(); - if (thread_.joinable()) - thread_.join(); + // Moved out under the lock, then joined without it: the reactor + // thread takes the same lock on every pass, so joining while + // holding it would deadlock. A context that never waited has no + // thread here at all. + std::thread t; + { + std::lock_guard lock(mutex_); + t = std::move(thread_); + } + if (t.joinable()) + t.join(); } inline void @@ -316,6 +417,18 @@ 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 -- + // and such an ask can outlive the op's next reset(). + // Acting on it then would complete the wait that reset + // started, the moment it is registered. Every real cancel + // flags the op before queueing the ask, and only reset() + // clears the flag, so an unflagged op is one of those + // stale asks. + if (!op->cancelled.load(std::memory_order_acquire)) + continue; + auto it = std::find_if( registered_.begin(), registered_.end(), [op](entry const& e) { return e.op == op; }); diff --git a/include/boost/corosio/native/detail/make_err.hpp b/include/boost/corosio/native/detail/make_err.hpp index 75dc01bbe..61028ca5a 100644 --- a/include/boost/corosio/native/detail/make_err.hpp +++ b/include/boost/corosio/native/detail/make_err.hpp @@ -59,8 +59,10 @@ make_err(int errn) noexcept /** Convert a Windows error code to std::error_code. Maps ERROR_OPERATION_ABORTED and ERROR_CANCELLED to - capy::error::canceled, and ERROR_HANDLE_EOF to capy::error::eof. - Every other code passes through std::system_category(). + capy::error::canceled, ERROR_HANDLE_EOF to capy::error::eof, and + the contracted WSA/Win32 codes to the `std::errc` conditions the + library promises. Every other code passes through + std::system_category(). ERROR_NETNAME_DELETED (64) is deliberately not mapped here: IOCP delivers it both for a local closesocket() that cancels pending I/O @@ -100,6 +102,12 @@ make_err(unsigned long dwError) noexcept return std::make_error_code(std::errc::address_not_available); if (dwError == ERROR_NEGATIVE_SEEK) return std::make_error_code(std::errc::invalid_argument); + // A thread the system will not give is the same retryable + // condition POSIX spells EAGAIN, which is what the contract + // promises; no toolchain maps the Win32 spelling to it. + if (dwError == ERROR_MAX_THRDS_REACHED) + return std::make_error_code( + std::errc::resource_unavailable_try_again); return std::error_code(static_cast(dwError), std::system_category()); } diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index a331218d0..397d09b48 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -74,11 +74,13 @@ struct post_awaitable void await_resume() const noexcept {} }; -// One entry point make_wakeup_pair calls, and the code to fail it with. +// One entry point make_wakeup_pair calls, the code to fail it with, +// and which of its calls to that entry point to hit. struct wakeup_arm { sys which; int err; + unsigned nth = 1; }; // An operation the wait reactor can no longer complete parks forever, @@ -103,19 +105,23 @@ struct iocp_faults { // Winsock is started once per process and released when the // last service goes, so this only fires while no io_context is - // alive (win_wsa_init.hpp:57-67). The resolver service that - // starts it is built inside the scheduler's constructor, after - // the completion port: the port is the scheduler's to release - // on the way out (win_scheduler.hpp:713-748). + // alive. The resolver service that starts it is built inside + // the scheduler's constructor, after the completion port: the + // port is the scheduler's to release on the way out. + // + // A lost port is exactly one handle per attempt, so the run is + // twice as long as the growth it allows: sixteen attempts have + // to stay under the eight handles of ambient drift the default + // shape tolerates. expect_no_handle_leak([]{ fault_scope f(sys::WSAStartup, WSAEAFNOSUPPORT); expect_system_error([]{ io_context ioc(iocp); }, std::errc::address_family_not_supported); BOOST_TEST(f.fired()); - }); + }, 16, 8); { // The scheduler's own port: CreateIoCompletionPort with - // INVALID_HANDLE_VALUE (win_scheduler.hpp:722-728). + // INVALID_HANDLE_VALUE. fault_scope f(sys::CreateIoCompletionPort, ERROR_INVALID_PARAMETER); expect_system_error([]{ io_context ioc(iocp); }, @@ -128,7 +134,7 @@ struct iocp_faults { // A null waitable timer is never reported: start() returns // without a thread and update_timeout() does nothing, so - // timers simply never fire (win_timers_thread.hpp:52,62-66). + // timers simply never fire. fault_scope f(sys::CreateWaitableTimerW, ERROR_NOT_ENOUGH_MEMORY); io_context ioc(iocp); BOOST_TEST(f.fired()); @@ -157,7 +163,7 @@ struct iocp_faults // The wait is the first thing the timer thread does, but the // thread starts asynchronously: destroying the context right // away can set the shutdown flag before the loop is entered - // and the wait never happens (win_timers_thread.hpp:135-141). + // and the wait never happens. // So the context is held until the arm reports, bounded so a // wait that never comes fails rather than hangs. // @@ -189,8 +195,7 @@ struct iocp_faults { io_context ioc(iocp); // The shutdown packet is the only thing that wakes a blocked - // run(), so a failed post is fatal rather than reported - // (win_scheduler.hpp:405-412). + // run(), so a failed post is fatal rather than reported. fault_scope f(sys::PostQueuedCompletionStatus, ERROR_NO_SYSTEM_RESOURCES); expect_system_error([&]{ ioc.stop(); }, @@ -208,8 +213,7 @@ struct iocp_faults { { // A failed post falls back to the allocating handle - // path, so the work still runs - // (win_scheduler.hpp:299-313). + // path, so the work still runs. fault_scope f(sys::PostQueuedCompletionStatus, ERROR_NO_SYSTEM_RESOURCES); co_await post_awaitable{&cont}; @@ -227,7 +231,7 @@ struct iocp_faults { io_context ioc(iocp); // A dequeue that reports failure with no OVERLAPPED is not a - // timeout, so the run loop throws (win_scheduler.hpp:663-669). + // timeout, so the run loop throws. fault_scope f(sys::GetQueuedCompletionStatus, ERROR_INVALID_HANDLE); auto body = [&]() -> capy::task<> { @@ -271,8 +275,8 @@ struct iocp_faults make_native_adoptable(h); expect_no_handle_leak([&]{ { - // SO_PROTOCOL_INFOW is how adoption learns the family - // and type (win_tcp_acceptor_service.hpp:1141-1147). + // SO_PROTOCOL_INFOW is how adoption learns the + // family and type. tcp_socket s(ioc); fault_scope f(sys::getsockopt, WSAENOTSOCK); auto ec = s.assign(h); @@ -349,8 +353,7 @@ struct iocp_faults tcp_socket s(ioc); BOOST_TEST(!s.open(tcp::v4())); // Severing the port association is best effort: the caller - // gets a working socket either way - // (win_tcp_acceptor_service.hpp:986-996). + // gets a working socket either way. fault_scope f(sys::NtSetInformationFile, ERROR_INVALID_PARAMETER); auto h = s.release(); BOOST_TEST(f.fired()); @@ -362,8 +365,7 @@ struct iocp_faults void testTcpExtensionPointerMissing() { // load_extension_functions runs once, from the tcp service's - // constructor, so the arm has to precede the io_context - // (win_tcp_acceptor_service.hpp:1241-1264). + // constructor, so the arm has to precede the io_context. fault_scope f(sys::WSAIoctl, WSAEOPNOTSUPP); io_context ioc(iocp); BOOST_TEST(f.fired()); @@ -396,8 +398,7 @@ struct iocp_faults { { // ConnectEx needs a bound socket, so an unbound one - // is bound to the wildcard first - // (win_tcp_acceptor_service.hpp:507-536). + // is bound to the wildcard first. tcp_socket s(ioc); BOOST_TEST(!s.open(tcp::v4())); fault_scope f(sys::bind, WSAEADDRNOTAVAIL); @@ -470,8 +471,7 @@ struct iocp_faults BOOST_TEST(f.fired()); } { - // A wait for readability is a zero-byte WSARecv - // (win_tcp_acceptor_service.hpp:740-756). + // A wait for readability is a zero-byte WSARecv. fault_scope f(sys::WSARecv, WSAENOTSOCK); auto [ec] = co_await a.wait(wait_type::read); wtec = ec; @@ -486,8 +486,7 @@ struct iocp_faults { // A remote reset reaches a pending read as // ERROR_NETNAME_DELETED, which off the accept path - // means connection_reset - // (win_overlapped_op.hpp:76-81). + // means connection_reset. completion_fault_scope q(ERROR_NETNAME_DELETED); auto [ec, n] = co_await a.read_some( capy::mutable_buffer(buf, sizeof(buf))); @@ -595,8 +594,7 @@ struct iocp_faults tcp_socket server(ioc); { // Writability carries no meaning for a listening - // socket and reaches no syscall - // (win_tcp_acceptor_service.hpp:1570-1574). + // socket and reaches no syscall. auto [ec] = co_await acc.wait(wait_type::write); waitec = ec; } @@ -651,8 +649,7 @@ struct iocp_faults auto [cec] = co_await client.connect(ep); BOOST_TEST(!cec); // On the accept path ERROR_NETNAME_DELETED means the - // half-open connection died, not a reset stream - // (win_overlapped_op.hpp:76-81). + // half-open connection died, not a reset stream. completion_fault_scope q(ERROR_NETNAME_DELETED); auto [ec] = co_await acc.accept(server); compec = ec; @@ -816,8 +813,7 @@ struct iocp_faults } { // A datagram connect is synchronous: WSAConnect - // either names the peer or reports why not - // (win_udp_service.hpp:566-576). + // either names the peer or reports why not. fault_scope f(sys::WSAConnect, WSAEAFNOSUPPORT); auto [ec] = co_await a.connect(b_ep); conec = ec; @@ -884,44 +880,92 @@ struct iocp_faults void testWaitReactorSetupFails() { - // make_wakeup_pair reports nothing: a failure leaves the - // reactor with no self-pipe, so a register that follows never - // reaches its poll set (win_wait_reactor.hpp:159-215). Nothing - // else is observable, and poll() is used rather than run() so - // a parked op cannot hang the suite. + // The wakeup channel is built as the scheduler is, right after + // the resolver service starts Winsock, and nothing before it + // in the construction reaches these entry points -- so the + // counts below are the pair's own. A reactor that cannot be + // woken can never report readiness, so the context refuses to + // construct rather than handing back one whose every wait + // would park forever. The codes are ones neither make_err nor + // iocp_make_err rewrites, so what is injected is what the + // constructor throws. static constexpr wakeup_arm arms[] = { {sys::socket, WSAEMFILE}, - {sys::bind, WSAEADDRINUSE}, - {sys::listen, WSAEOPNOTSUPP}, - {sys::connect, WSAECONNREFUSED}, - {sys::accept, WSAENOTSOCK}, + {sys::bind, WSAEACCES}, + {sys::listen, WSAEINVAL}, + {sys::getsockname, WSAEFAULT}, + // The second socket() in make_wakeup_pair is the peer that + // connects to the listener. + {sys::socket, WSAENOBUFS, 2u}, + {sys::connect, WSAENETDOWN}, + {sys::accept, WSAEINPROGRESS}, + {sys::ioctlsocket, WSAENOTCONN}, }; - for(auto const& a : arms) + // The completion port, the timer wakeup and whichever wakeup + // sockets were already open are all the failed construction's + // to release. A leak of one handle per attempt has to separate + // from the ambient drift, so each arm runs sixteen times + // against a growth budget of eight. + for(auto const& arm : arms) { - io_context ioc(iocp); - auto pair = make_socket_pair(ioc); - auto& s1 = pair.first; - auto& s2 = pair.second; - // The arm outlives the coroutine: a wait the reactor - // cannot report never resumes it, so `fired()` has to be - // readable from here. - std::optional arm; - auto body = [&]() -> capy::task<> + expect_no_handle_leak([&]{ + fault_scope f(arm.which, arm.err, arm.nth); + expect_system_error([]{ io_context ioc(iocp); }, + win_err(arm.err)); + BOOST_TEST(f.fired()); + }, 16, 8); + } + } + + // A context that constructs owns a wakeup channel, so the reactor + // has something to poll and the wait it is handed completes. The + // polling thread is the half that waits for a reason to exist: one + // that was already running would be sitting in WSAPoll, so a + // context that never waits leaves the arm below unspent. + void testWaitReactorStartsOnFirstWait() + { + if(!hook_is_live(sys::WSAPoll)) + { + skip_dead_hook("WSAPoll"); + } + else + { + // any_thread: a thread that had started would be polling + // on its own, not on this one. The arm outlives the + // context so a poll begun during teardown counts too. + fault_scope f(sys::WSAPoll, WSAENOBUFS, 1u, any_thread); { - arm.emplace(a.which, a.err, 1u); - std::ignore = co_await s1.wait(wait_type::write); - }; - capy::run_async(ioc.get_executor())(body()); - std::ignore = ioc.poll(); - s1.cancel(); - std::ignore = ioc.poll(); - // The reactor is built inside the wait above, on this - // thread, so the arm is settled by the time poll returns. - BOOST_TEST(arm.has_value() && arm->fired()); - arm.reset(); - s1.close(); - s2.close(); + io_context quiet(iocp); + udp_socket s(quiet); + BOOST_TEST(!s.open(udp::v4())); + BOOST_TEST(!s.bind(loopback())); + s.close(); + } + BOOST_TEST(!f.fired()); } + + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code wec; + bool done = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + auto [ec] = co_await s1.wait(wait_type::write); + wec = ec; + 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); + s1.close(); + s2.close(); } void testWaitReactorPollFails() @@ -947,7 +991,7 @@ struct iocp_faults // Armed after the wait above is queued, so whichever poll // fails first already has that op in hand: the drain on // the way out covers registered_ and pending_register_ - // alike (win_wait_reactor.hpp:344-348, 400-412). + // 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. @@ -1009,8 +1053,7 @@ struct iocp_faults expect_no_handle_leak([&]{ { // Adoption learns the family and type from - // SO_PROTOCOL_INFOW here too - // (win_local_stream_service.hpp:990-1000). + // SO_PROTOCOL_INFOW here too. local_stream_socket s(ioc); fault_scope f(sys::getsockopt, WSAENOTSOCK); BOOST_TEST(s.assign(h) == std::errc::not_a_socket); @@ -1081,8 +1124,7 @@ struct iocp_faults { { // AF_UNIX ConnectEx also needs a bound socket, which - // it satisfies with a family-only sockaddr_un - // (win_local_stream_service.hpp:419-434). + // it satisfies with a family-only sockaddr_un. local_stream_socket s(ioc); BOOST_TEST(!s.open()); fault_scope f(sys::bind, WSAEADDRNOTAVAIL); @@ -1121,8 +1163,7 @@ struct iocp_faults if(hook_is_live(sys::AcceptEx)) { // The AF_UNIX accept reaches the same substituted - // pointer as the tcp one - // (win_local_stream_acceptor_service.hpp:415-425). + // pointer as the tcp one. local_stream_socket c(ioc); BOOST_TEST(!c.open()); auto [cec] = co_await c.connect(ep); @@ -1187,6 +1228,7 @@ struct iocp_faults testUdpSetupFails(); testUdpIoFails(); testWaitReactorSetupFails(); + testWaitReactorStartsOnFirstWait(); testWaitReactorPollFails(); testLocalSetupFails(); testLocalConnectAcceptFails(); From f208ba5b47d28919b689466ba83c22f659c0cc9b Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:22 +0200 Subject: [PATCH 11/34] fix(io_uring): create the ring when the io_context is constructed The ring was created lazily on the first run() or operation, so an initialization failure escaped from whichever call happened first. It is now created at the end of every io_context constructor, after the options that select its flags have been applied, and a failure throws from the constructor like every other backend. A failed initialization rolls back the wakeup eventfd it opened. The fault harness rewrites completions from every submit shadow, since a buffered write can complete inside the submit itself. --- include/boost/corosio/io_context.hpp | 36 +++++++++++-- .../detail/io_uring/io_uring_scheduler.hpp | 42 +++++++++++---- src/corosio/src/io_context.cpp | 20 +++++++- test/unit/fault/fault_uring.cpp | 9 +++- test/unit/fault/uring_faults.cpp | 51 ++++++++----------- 5 files changed, 110 insertions(+), 48 deletions(-) diff --git a/include/boost/corosio/io_context.hpp b/include/boost/corosio/io_context.hpp index 201ff7833..cbae8d45f 100644 --- a/include/boost/corosio/io_context.hpp +++ b/include/boost/corosio/io_context.hpp @@ -224,6 +224,13 @@ effective_concurrency_hint( `capy::run` / `capy::run_async` is work-tracked, so a normal `run()` completion already waits for it. + @par Exception Safety + A context that constructs is usable. The infrastructure its + backend needs — the completion port, the ring, the reactor's + wakeup channel — is created during construction, so a system that + refuses it throws from the constructor rather than from the first + operation, and the failed construction leaves nothing open. + @par Thread Safety Distinct objects: Safe.@n Shared objects: Safe, unless the context was constructed with a @@ -237,15 +244,19 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context /// Pre-create services that depend on options (before construct). void apply_options_pre_(io_context_options const& opts); - /// Apply runtime tuning to the scheduler (after construct). + /** Apply runtime tuning to the scheduler and finish bringing the + backend up. The tail of every options constructor: the backend + infrastructure whose setup reads these options is created here, + so a failure to create it throws from the constructor. */ void apply_options_post_( io_context_options const& opts, unsigned concurrency_hint); - /** Apply only the decomposed threading configuration (locking tiers). - Used by the plain constructors, which — unlike the options - constructors — deliberately leave the reactor budget at its defaults - rather than engaging the multi-thread post-everything heuristic. */ + /** Apply only the decomposed threading configuration (locking tiers), + then finish bringing the backend up. The tail of every plain + constructor, which — unlike the options constructors — + deliberately leaves the reactor budget at its defaults rather than + engaging the multi-thread post-everything heuristic. */ void apply_threading_(io_context_options const& opts); protected: @@ -261,6 +272,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context case it reports 0) as the concurrency hint, and the default @ref locking_mode::safe tier. Select a lockless tier via @ref io_context_options::locking. + + @throws std::system_error If the backend's infrastructure + could not be created. */ io_context(); @@ -268,6 +282,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context @param concurrency_hint Hint for the number of threads that will call `run()`. + + @throws std::system_error If the backend's infrastructure + could not be created. */ explicit io_context(unsigned concurrency_hint); @@ -280,6 +297,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context @throws std::invalid_argument If `opts.thread_pool_size` is less than 1 (POSIX). + + @throws std::system_error If the backend's infrastructure + could not be created. */ explicit io_context( io_context_options const& opts, @@ -291,6 +311,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context multiplexer (e.g. `corosio::epoll`). @param concurrency_hint Hint for the number of threads that will call `run()`. + + @throws std::system_error If the backend's infrastructure + could not be created. */ template requires requires { Backend::construct; } @@ -317,6 +340,9 @@ class BOOST_COROSIO_DECL io_context : public capy::execution_context @throws std::invalid_argument If `opts.thread_pool_size` is less than 1 (POSIX). + + @throws std::system_error If the backend's infrastructure + could not be created. */ template requires requires { Backend::construct; } 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 7a8e3ed0c..6ffea397c 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 @@ -184,6 +184,26 @@ class BOOST_COROSIO_DECL io_uring_scheduler final /// Initialize the io_uring ring on first access. Idempotent. void lazy_init_ring() const; + /** Create the io_uring ring. + + Called once the owning `io_context` has applied every option + that feeds the ring's setup flags, so that a kernel that + refuses the ring is reported from the `io_context` + constructor rather than from the first operation submitted + through it. Idempotent. + + @par Exception Safety + Strong guarantee. A ring that cannot be created leaves no + descriptor behind. + + @throws std::system_error If the ring, its wakeup eventfd, or + the poll watching that eventfd could not be set up. + */ + void init_ring() const + { + lazy_init_ring(); + } + /// Wake the leader if it's blocked in `submit_and_wait_timeout`. /// Best-effort: the wakeup is suppressed if the leader has already /// been signalled and not yet acked. @@ -341,9 +361,8 @@ class BOOST_COROSIO_DECL io_uring_scheduler final /** Configure SQPOLL parameters. - Must be called before the first run/poll/post — the values - are cached and read by `lazy_init_ring_unlocked` when the - ring is first constructed. No-op if `enable` is false (the + Must be called before the ring is created — the values are + cached and read when it is. No-op if `enable` is false (the default). @note When combined with single-threaded mode, @@ -493,9 +512,9 @@ class BOOST_COROSIO_DECL io_uring_scheduler final static constexpr int drain_cqes_max_rounds = 8; static constexpr unsigned long drain_cqes_kick_ns = 1'000'000; - // ring_inited_ goes true once on first run/poll/submit. The init is - // deferred from the constructor so configure_threading() can take - // effect before io_uring_queue_init_params chooses flags. + // ring_inited_ goes true once the ring exists. The init is deferred + // from the constructor so configure_threading() and configure_sqpoll() + // can take effect before io_uring_queue_init_params chooses flags. mutable std::once_flag ring_init_once_; mutable bool ring_inited_ = false; @@ -525,9 +544,12 @@ io_uring_scheduler::io_uring_scheduler( get_resolver_service(ctx, *this); get_signal_service(ctx, *this); - // Ring init is deferred to lazy_init_ring() so configure_single_- - // threaded(true), which the io_context applies after construction, - // can take effect before io_uring_queue_init_params chooses flags. + // Ring init is deferred so the options the io_context applies + // after this constructor — the locking tier and SQPOLL — can feed + // the flags io_uring_queue_init_params is given. The io_context + // calls init_ring() once they are in place; the lazy_init_ring() + // calls scattered through the op paths only matter for a + // scheduler used without one. } inline @@ -633,6 +655,7 @@ io_uring_scheduler::lazy_init_ring_unlocked() const if (!sqe) { ::close(wakeup_eventfd_); + wakeup_eventfd_ = -1; ::io_uring_queue_exit(&ring_); detail::throw_system_error( make_err(ENOSPC), "io_uring_get_sqe (wakeup)"); @@ -647,6 +670,7 @@ io_uring_scheduler::lazy_init_ring_unlocked() const if (submit_rc < 0) { ::close(wakeup_eventfd_); + wakeup_eventfd_ = -1; ::io_uring_queue_exit(&ring_); detail::throw_system_error( make_err(-submit_rc), "io_uring_submit (wakeup)"); diff --git a/src/corosio/src/io_context.cpp b/src/corosio/src/io_context.cpp index 7957cb8ac..207ee195f 100644 --- a/src/corosio/src/io_context.cpp +++ b/src/corosio/src/io_context.cpp @@ -238,6 +238,20 @@ apply_scheduler_options( } +// Bring up backend infrastructure whose setup depends on the options +// applied above. Runs last in every constructor: an io_context that +// constructs is usable, so a kernel that refuses the infrastructure is +// reported from the constructor and not from the first operation. +void +finish_construction([[maybe_unused]] detail::scheduler& sched) +{ +#if BOOST_COROSIO_HAS_IO_URING + if (auto* uring_sched = + dynamic_cast(&sched)) + uring_sched->init_ring(); +#endif +} + detail::scheduler& construct_default(capy::execution_context& ctx, unsigned concurrency_hint) { @@ -274,13 +288,13 @@ io_context::io_context( : capy::execution_context(this) , sched_(nullptr) { - pre_create_services(*this, opts_in); + apply_options_pre_(opts_in); // Computed before construct_default so IOCP's completion port is created // with the effective concurrency. unsigned const eff = detail::effective_concurrency_hint(opts_in, concurrency_hint); sched_ = &construct_default(*this, eff); - apply_scheduler_options(*sched_, opts_in, eff); + apply_options_post_(opts_in, eff); } void @@ -295,12 +309,14 @@ io_context::apply_options_post_( unsigned concurrency_hint) { apply_scheduler_options(*sched_, opts_in, concurrency_hint); + finish_construction(*sched_); } void io_context::apply_threading_(io_context_options const& opts_in) { sched_->configure_threading(make_threading_config(opts_in)); + finish_construction(*sched_); } io_context::~io_context() diff --git a/test/unit/fault/fault_uring.cpp b/test/unit/fault/fault_uring.cpp index 7fcab9b70..d2fd422fb 100644 --- a/test/unit/fault/fault_uring.cpp +++ b/test/unit/fault/fault_uring.cpp @@ -134,7 +134,14 @@ extern "C" int io_uring_submit(io_uring* ring) LIBURING_NOEXCEPT return 0; } scan_pending_sqes(ring); - return real(ring); + rc = real(ring); + // A buffered write can complete inside this io_uring_enter, so the + // CQE the arm is waiting for may already be visible when it + // returns. Rewriting here as well as in the waiting entry points + // is what keeps the arm from depending on which call the kernel + // chose to finish the op in. + rewrite_visible_cqes(ring); + return rc; } extern "C" int io_uring_submit_and_wait_timeout(io_uring* ring, io_uring_cqe** cqe, diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp index 69a401f7a..527516beb 100644 --- a/test/unit/fault/uring_faults.cpp +++ b/test/unit/fault/uring_faults.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -55,38 +56,28 @@ endpoint uring_loopback() struct uring_faults { - // The ring is created lazily, so init faults surface from the first - // operation that needs it rather than from the io_context ctor. + // The ring is created at the end of io_context construction, so a + // ring the kernel refuses leaves no context behind to run: the + // failure surfaces from the constructor, and what it opened on the + // way in is released as the constructor unwinds. void testRingInitFails() { auto expect = [](sys s, int err, std::errc code) { - io_context ioc(io_uring); + int const before = open_fds(); fault_scope f(s, err); - expect_system_error([&]{ ioc.run(); }, code); + expect_system_error([&]{ io_context ioc(io_uring); }, code); BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); }; expect(sys::io_uring_queue_init_params, ENOMEM, std::errc::not_enough_memory); expect(sys::eventfd, EMFILE, std::errc::too_many_files_open); - // The wakeup poll's submit is the only one init issues. + // The wakeup poll's submit is the only one ring creation issues. expect(sys::io_uring_submit, EBADF, std::errc::bad_file_descriptor); } - void testRingInitLeaksNothing() - { - int before = open_fds(); - { - io_context ioc(io_uring); - fault_scope f(sys::io_uring_submit, EBADF); - expect_system_error([&]{ ioc.run(); }, - std::errc::bad_file_descriptor); - BOOST_TEST(f.fired()); - } - BOOST_TEST_EQ(open_fds(), before); - } - void testSignalReaderSubmitFails() { // A successful add opens the process-global self-pipe and @@ -98,9 +89,10 @@ struct uring_faults std::error_code ec; bool fired = false; { - // nth = 2: the first submit arms the wakeup eventfd - // when the ring is created. - fault_scope f(sys::io_uring_submit, EBADF, 2); + // The wakeup eventfd's submit is spent building the + // ring, before this arm, so the reader's is the first + // one it sees. + fault_scope f(sys::io_uring_submit, EBADF); ec = ss.add(SIGUSR2); fired = f.fired(); } @@ -118,17 +110,15 @@ struct uring_faults void testSignalReaderSqFull() { in_child([]{ + // The clamp sizes the ring as it is created, which happens + // while the context is built, so it is armed before that. + std::optional f; + f.emplace(sys::uring_sqe_full, 0); io_context ioc(io_uring); signal_set ss(ioc); - std::error_code ec; - bool fired = false; - { - // The ring is created lazily, so the clamp still - // catches it from inside add(). - fault_scope f(sys::uring_sqe_full, 0); - ec = ss.add(SIGUSR2); - fired = f.fired(); - } + std::error_code const ec = ss.add(SIGUSR2); + bool const fired = f->fired(); + f.reset(); // Not latched: with the SQ flushable again the next add() // arms the reader. return fired && @@ -620,7 +610,6 @@ struct uring_faults if(skip_under_valgrind()) return; testRingInitFails(); - testRingInitLeaksNothing(); testSignalReaderSubmitFails(); testSignalReaderSqFull(); testWaitFails(); From 9a33cfe7960eb6595395766593646ae65a22925a Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:23 +0200 Subject: [PATCH 12/34] fix: make a failed self-wake best-effort on every backend The IOCP wait reactor treated a failed wakeup send as fatal from inside a noexcept function, and epoll and kqueue latched their armed flag before the wake call and discarded its result, so one failure disabled every later interrupt. A failed wake now leaves the flag clear so the next interrupt retries; the only cost is the interrupts already in flight. Fault tests arm the wake call to fail once on epoll, kqueue and IOCP and assert a later stop() still wakes the loop. --- doc/error-handling-rulebook.md | 6 ++ .../native/detail/epoll/epoll_scheduler.hpp | 14 +++- .../detail/io_uring/io_uring_scheduler.hpp | 11 +-- .../native/detail/iocp/win_wait_reactor.hpp | 48 +++++++++---- .../native/detail/kqueue/kqueue_scheduler.hpp | 12 +++- test/unit/fault/epoll_faults.cpp | 43 ++++++++++++ test/unit/fault/iocp_faults.cpp | 70 +++++++++++++++++++ test/unit/fault/kqueue_faults.cpp | 40 +++++++---- test/unit/fault/select_faults.cpp | 38 ++++++++++ 9 files changed, 240 insertions(+), 42 deletions(-) diff --git a/doc/error-handling-rulebook.md b/doc/error-handling-rulebook.md index f513702c7..a2a61dc43 100644 --- a/doc/error-handling-rulebook.md +++ b/doc/error-handling-rulebook.md @@ -26,6 +26,12 @@ by classifying its failures: would be an attractive nuisance: the retry it invites is a double-close hazard, because POSIX leaves descriptor release under `EINTR` unspecified. +- **No caller at all** — an internal wakeup (`interrupt_reactor()`, + `wake_self()`) has nobody to report to, but swallowing the failure + must not swallow every later wake too: a coalescing flag stands for + a byte a failed write never sent, so the failure path disarms it. + The cost is then the wakes already in flight rather than every wake + after them. Never throw from a wake path. - Never both channels for one operation. Never `std::error_code&` out-params. Never a throwing/non-throwing overload pair. diff --git a/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp b/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp index e87c69e55..77ee43e84 100644 --- a/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp +++ b/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp @@ -294,8 +294,18 @@ epoll_scheduler::interrupt_reactor() const expected, true, std::memory_order_release, std::memory_order_relaxed)) { - std::uint64_t val = 1; - [[maybe_unused]] auto r = ::write(event_fd_, &val, sizeof(val)); + std::uint64_t val = 1; + if (::write(event_fd_, &val, sizeof(val)) < 0) + { + // The flag is what coalesces later interrupts into a byte + // already in the eventfd; a write that failed put no byte + // there, so leaving it armed would swallow every interrupt + // that follows. Disarming keeps the cost to the interrupts + // already in flight -- the next one arms and writes again, + // instead of every one after this coalescing into a byte + // that does not exist. + eventfd_armed_.store(false, std::memory_order_release); + } } } 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 6ffea397c..2f5b471fb 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 @@ -438,7 +438,6 @@ class BOOST_COROSIO_DECL io_uring_scheduler final int sq_thread_cpu_ = -1; 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 @@ -788,24 +787,18 @@ io_uring_scheduler::interrupt_reactor() const noexcept // (drained together by drain_wakeup_eventfd's single read of // the eventfd counter). std::uint64_t v = 1; - [[maybe_unused]] auto r = ::write(wakeup_eventfd_, &v, sizeof(v)); - wakeup_armed_.store(true, std::memory_order_release); + std::ignore = ::write(wakeup_eventfd_, &v, sizeof(v)); } inline void io_uring_scheduler::drain_wakeup_eventfd() const noexcept { std::uint64_t v; - [[maybe_unused]] auto r = ::read(wakeup_eventfd_, &v, sizeof(v)); + std::ignore = ::read(wakeup_eventfd_, &v, sizeof(v)); // Multishot poll never needs re-arming. The poll-add was queued // once at lazy_init_ring with IORING_POLL_ADD_MULTI; each eventfd // POLLIN produces a CQE without consuming the SQE. - // - // Release pairs with the acquire side of interrupt_reactor's CAS: - // a posting thread that observes wakeup_armed_ == false from this - // store will see the eventfd already drained by the leader. - wakeup_armed_.store(false, std::memory_order_release); } inline bool 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 412933aba..8b5d579ce 100644 --- a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp @@ -169,6 +169,14 @@ class win_wait_reactor : private win_wsa_init std::atomic stop_{false}; std::atomic wake_pending_{false}; + // Set by the polling thread on its way out, guarded by mutex_. + // stop() is the ordinary way the thread leaves and has stop_ to + // announce it; this covers the thread leaving on its own after a + // WSAPoll error, which stop_ must not be used for -- stop() reads + // it as "already stopped" and would skip the join that keeps the + // thread from being destroyed joinable. + bool dead_ = false; + std::vector registered_; // reactor-thread-only std::thread thread_; @@ -296,13 +304,13 @@ win_wait_reactor::wake_self() noexcept char b = 0; if (::send(wakeup_write_, &b, 1, 0) == SOCKET_ERROR) { - // The self-pipe byte is the only thing that wakes the reactor - // thread from an indefinite poll(); a coalesced lost wakeup - // would leave wake_pending_ stuck true and hang the op. - // wakeup_write_ is a blocking socket, so a 1-byte send can - // only fail on a hard error -- fatal, mirroring a failed - // PostQueuedCompletionStatus in win_scheduler. - detail::throw_system_error(make_err(::WSAGetLastError())); + // The flag is what coalesces later wakes into a byte already + // in the channel; a send that failed put no byte there, so + // leaving it latched would swallow every wake that follows. + // Disarming keeps the cost at the one wake that failed: the + // next register, cancel or stop sends its own byte and the + // reactor learns about both. + wake_pending_.store(false, std::memory_order_release); } } @@ -323,9 +331,9 @@ win_wait_reactor::register_wait( if (DWORD const err = queue_register(entry{fd, w, op}); err != 0) { - // The reactor is stopped, so nothing would ever drain a parked - // op. Report the abort its own shutdown drain gives the ops it - // was still holding. + // The reactor is stopped, or its polling thread has died, so + // nothing would ever drain a parked op. Report the abort its + // own shutdown drain gives the ops it was still holding. sched_.on_completion(op, err, 0); return; } @@ -343,7 +351,10 @@ win_wait_reactor::queue_register(entry const& e) // which ends the process, and an op queued after it would have no // drainer. Queueing under the flag instead leaves the op for the // drain run() performs on its way out. - if (stop_.load(std::memory_order_acquire)) + // dead_ says the same thing for the other exit: a thread that left + // on a WSAPoll error drained what it held and will not poll again, + // so a register queued after it would wait on nobody. + if (stop_.load(std::memory_order_acquire) || dead_) return ERROR_OPERATION_ABORTED; // A polling thread costs a thread per context, and a context that @@ -450,10 +461,12 @@ win_wait_reactor::run() pollfds.push_back({e.fd, events_for_wait(e.w), 0}); // Block until the self-pipe (slot 0) is poked by a register, - // cancel, or stop, or a watched socket becomes ready. There is - // no periodic safety-net timeout: a lost self-pipe wakeup is - // fatal in wake_self() (the byte send can only fail on a hard - // error), so an idle reactor consumes no CPU. + // cancel, or stop, or a watched socket becomes ready. No + // periodic timeout, so an idle reactor consumes no CPU: the + // self-pipe is the only thing that ends this wait, and + // wake_self() leaves the channel free for the next poke when + // its own send fails, so what a lost wake costs is that one + // wake rather than every wake after it. int n = ::WSAPoll( pollfds.data(), static_cast(pollfds.size()), @@ -517,6 +530,11 @@ win_wait_reactor::run() // ops leak work_started credit and stall scheduler shutdown. { std::lock_guard lock(mutex_); + // Closing the door and taking what is behind it in one critical + // section is what leaves no register in between: one that got + // in is drained here, one that arrives after is refused by + // queue_register and completes as aborted at its caller. + dead_ = true; for (auto& e : pending_register_) registered_.push_back(e); pending_register_.clear(); diff --git a/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp index 5eb156d40..87c4e7eba 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp @@ -289,7 +289,17 @@ kqueue_scheduler::interrupt_reactor() const { struct kevent ev; EV_SET(&ev, 0, EVFILT_USER, 0, NOTE_TRIGGER, 0, nullptr); - ::kevent(kq_fd_, &ev, 1, nullptr, 0, nullptr); + if (::kevent(kq_fd_, &ev, 1, nullptr, 0, nullptr) < 0) + { + // The flag is what coalesces later interrupts into a + // trigger already queued on the kqueue; a kevent that + // failed queued nothing, so leaving it armed would swallow + // every interrupt that follows. Disarming keeps the cost to + // the interrupts already in flight -- the next one arms and + // triggers again, instead of every one after this + // coalescing into a trigger that does not exist. + user_event_armed_.store(false, std::memory_order_release); + } } } diff --git a/test/unit/fault/epoll_faults.cpp b/test/unit/fault/epoll_faults.cpp index 37c8f98dd..860872efc 100644 --- a/test/unit/fault/epoll_faults.cpp +++ b/test/unit/fault/epoll_faults.cpp @@ -210,6 +210,48 @@ struct epoll_faults } } + /* A wake that never left has to be retried, not swallowed. + + `eventfd_armed_` is set before the write, so it stands for a byte + in the eventfd. A write that failed put none there: leaving the + flag set would coalesce every later interrupt into a wake that + does not exist, and a wait only an interrupt could end would + never end. The second arm is the observable — it can only fire if + the second interrupt actually reached `write`. + */ + void testInterruptWriteFails() + { + io_context ioc(epoll); + { + // stop() is the one interrupt that reaches the eventfd + // without a reactor thread (reactor_scheduler::stop), and + // it interrupts only on the transition, so each stop below + // is one write. + fault_scope first(sys::write, EIO, 1); + fault_scope second(sys::write, EIO, 2); + ioc.stop(); + BOOST_TEST(first.fired()); + BOOST_TEST(!second.fired()); + ioc.restart(); + ioc.stop(); + BOOST_TEST(second.fired()); + } + // And the loop the failed wakes were aimed at still runs: timed + // work fires and a stop from inside ends run(). + ioc.restart(); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(done); + } + void testSignalReaderRegisterFails() { in_child([]{ @@ -237,6 +279,7 @@ struct epoll_faults testAcceptorRegisterFails(); testAcceptFails(); testRunLoopFaults(); + testInterruptWriteFails(); testSignalReaderRegisterFails(); } }; diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index 397d09b48..7c21aa81f 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -968,6 +968,74 @@ struct iocp_faults 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 + the wakeup channel, so a send that failed leaves it claiming a + byte nobody sent. Every later register, cancel and stop then + coalesces into that phantom, and the poll they were meant to end + is indefinite. + + This context never registers a wait, so no polling thread is + ever started and nothing drains the flag -- a latch here would + be permanent, which is what makes the second send the + observable. Both sends come from the socket: open() goes + through close_socket, which asks the reactor to drop any wait op + the descriptor might have had, and cancel() asks again. Both + asks reach wake_self whether or not anything is parked + (win_tcp_socket_internal::cancel and ::close_socket). + */ + void testWakeSendFails() + { + if(!hook_is_live(sys::send)) + { + skip_dead_hook("send"); + return; + } + { + io_context ioc(iocp); + // Thread-local, not any_thread: two process-wide scopes + // cannot be alive at once, and every call below is on this + // thread. Armed before open(), whose own ask is the first + // send and the one that has to leave the flag clear. + fault_scope first(sys::send, WSAENOBUFS, 1); + fault_scope second(sys::send, WSAENOBUFS, 2); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + s.cancel(); + BOOST_TEST(first.fired()); + BOOST_TEST(second.fired()); + s.close(); + } + // And the reactor those wakes were aimed at still works: a + // real wait registers, parks, and is ended by a cancel. + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code parked_ec; + bool expired = false; + auto parked = [&]() -> capy::task<> + { + auto [ec] = co_await s1.wait(wait_type::error); + parked_ec = ec; + ioc.stop(); + }; + auto breaker = [&]() -> capy::task<> + { + s1.cancel(); + co_return; + }; + capy::run_async(ioc.get_executor())(parked()); + capy::run_async(ioc.get_executor())(breaker()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(parked_ec == capy::error::canceled); + s1.close(); + s2.close(); + } + void testWaitReactorPollFails() { io_context ioc(iocp); @@ -1006,6 +1074,7 @@ struct iocp_faults BOOST_TEST(arm.has_value() && arm->fired()); BOOST_TEST(parked_ec == capy::error::canceled); arm.reset(); + s1.close(); s2.close(); } @@ -1229,6 +1298,7 @@ struct iocp_faults testUdpIoFails(); testWaitReactorSetupFails(); testWaitReactorStartsOnFirstWait(); + testWakeSendFails(); testWaitReactorPollFails(); testLocalSetupFails(); testLocalConnectAcceptFails(); diff --git a/test/unit/fault/kqueue_faults.cpp b/test/unit/fault/kqueue_faults.cpp index 3c08baddf..04fe1c9e5 100644 --- a/test/unit/fault/kqueue_faults.cpp +++ b/test/unit/fault/kqueue_faults.cpp @@ -336,25 +336,34 @@ struct kqueue_faults } } - void testInterruptTriggerIgnored() + /* A NOTE_TRIGGER that never left has to be retried, not swallowed. + + `user_event_armed_` is set before the kevent, so it stands for a + trigger queued on the kqueue. A kevent that failed queued none: + leaving the flag set would coalesce every later interrupt into a + wake that does not exist, and a wait only an interrupt could end + would never end. The second arm is the observable — it can only + fire if the second interrupt actually reached kevent. + */ + void testInterruptTriggerFails() { io_context ioc(kqueue); { - // stop() interrupts unconditionally - // (reactor_scheduler.hpp:566-573), which is the one path - // that reaches NOTE_TRIGGER without a reactor thread. The - // result is discarded, and user_event_armed_ stays latched - // even though nothing was ever queued on the kqueue - // (kqueue_scheduler.hpp:285-293) — so no later interrupt - // can wake a blocked wait either. - fault_scope f(sys::kevent, EIO); + // stop() is the one path that reaches NOTE_TRIGGER without + // a reactor thread (reactor_scheduler::stop), and it + // interrupts only on the transition, so each stop below is + // one kevent. + fault_scope first(sys::kevent, EIO, 1); + fault_scope second(sys::kevent, EIO, 2); ioc.stop(); - BOOST_TEST(f.fired()); + BOOST_TEST(first.fired()); + BOOST_TEST(!second.fired()); + ioc.restart(); + ioc.stop(); + BOOST_TEST(second.fired()); } - // What survives the latch is the timeout: the wait is computed - // from the nearest expiry, so timed work still completes and - // run() still returns. Asserting anything about a wait that - // only an interrupt could end would be asserting a hang. + // And the loop the failed wakes were aimed at still runs: timed + // work fires and a stop from inside ends run(). ioc.restart(); bool done = false; auto body = [&]() -> capy::task<> @@ -362,6 +371,7 @@ struct kqueue_faults std::ignore = co_await corosio::delay( std::chrono::milliseconds(1)); done = true; + ioc.stop(); }; capy::run_async(ioc.get_executor())(body()); ioc.run(); @@ -400,7 +410,7 @@ struct kqueue_faults testAcceptFails(); testAcceptConfigureFails(); testRunLoopFaults(); - testInterruptTriggerIgnored(); + testInterruptTriggerFails(); testSignalReaderRegisterFails(); } }; diff --git a/test/unit/fault/select_faults.cpp b/test/unit/fault/select_faults.cpp index 7d6ac6767..7549be054 100644 --- a/test/unit/fault/select_faults.cpp +++ b/test/unit/fault/select_faults.cpp @@ -215,6 +215,43 @@ struct select_faults } } + /* This backend coalesces nothing: every interrupt writes its own + byte to the self-pipe, so a failed write costs exactly that one + interrupt. The arms below are what holds that shape in place — + the eventfd and kqueue backends reach it by clearing a flag, and + a flag introduced here would have to clear one too. + */ + void testInterruptWriteFails() + { + io_context ioc(select); + { + // stop() is the one interrupt that reaches the self-pipe + // without a reactor thread (reactor_scheduler::stop), and + // it interrupts only on the transition, so each stop below + // is one write. + fault_scope first(sys::write, EIO, 1); + fault_scope second(sys::write, EIO, 2); + ioc.stop(); + BOOST_TEST(first.fired()); + BOOST_TEST(!second.fired()); + ioc.restart(); + ioc.stop(); + BOOST_TEST(second.fired()); + } + ioc.restart(); + bool done = false; + auto body = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + done = true; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(done); + } + void run() { if(skip_under_valgrind()) @@ -224,6 +261,7 @@ struct select_faults testAcceptFails(); testAcceptFcntlFails(); testRunLoopFaults(); + testInterruptWriteFails(); } }; From c10c271adc5c5ec14d5a257f52ab159264fa54b3 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:24 +0200 Subject: [PATCH 13/34] fix(iocp): stop accepting waits after the poll thread dies When WSAPoll failed the reactor thread left its loop and drained the parked waits as cancelled, but a wait registered afterwards parked with nothing to complete it. The reactor now records that it is dead under the same lock that drains the queue, and later registrations and cancellations complete as cancelled at the caller. --- .../native/detail/iocp/win_wait_reactor.hpp | 14 ++++++++++--- test/unit/fault/iocp_faults.cpp | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 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 8b5d579ce..0e3aec1df 100644 --- a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp @@ -307,7 +307,7 @@ win_wait_reactor::wake_self() noexcept // The flag is what coalesces later wakes into a byte already // in the channel; a send that failed put no byte there, so // leaving it latched would swallow every wake that follows. - // Disarming keeps the cost at the one wake that failed: the + // Disarming keeps the cost to the wakes already in flight: the // next register, cancel or stop sends its own byte and the // reactor learns about both. wake_pending_.store(false, std::memory_order_release); @@ -383,6 +383,14 @@ win_wait_reactor::cancel_wait(overlapped_op* op) { { std::lock_guard lock(mutex_); + // Same refusal queue_register makes, for the same reason: once + // the polling thread is gone nothing reads pending_cancel_ + // again, so a cancel queued here would sit there for good. It + // has nothing to cancel either -- the drain those exits run + // already answered whatever was parked, and a register that + // arrived after them was refused at its caller. + if (stop_.load(std::memory_order_acquire) || dead_) + return; pending_cancel_.push_back(op); } wake_self(); @@ -465,8 +473,8 @@ win_wait_reactor::run() // periodic timeout, so an idle reactor consumes no CPU: the // self-pipe is the only thing that ends this wait, and // wake_self() leaves the channel free for the next poke when - // its own send fails, so what a lost wake costs is that one - // wake rather than every wake after it. + // its own send fails, so a lost wake costs the wakes already in + // flight rather than every wake after it. int n = ::WSAPoll( pollfds.data(), static_cast(pollfds.size()), diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index 7c21aa81f..8adb9355a 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -1075,6 +1075,27 @@ struct iocp_faults BOOST_TEST(parked_ec == capy::error::canceled); arm.reset(); + // The polling thread left for good and nothing restarts it, so + // a wait registered afterwards has nobody to report its + // readiness. Refusing it is the only answer that is not a park + // forever, and it is the same abort the drain on the way out + // gave the ops the reactor was holding. io_context::stop() does + // not reach the reactor, so this is the reactor's own state + // answering and not a stopped scheduler. + ioc.restart(); + std::error_code late_ec; + bool late_expired = false; + auto late = [&]() -> capy::task<> + { + auto [ec] = co_await s1.wait(wait_type::error); + late_ec = ec; + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(late()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, late_expired)); + ioc.run(); + BOOST_TEST(!late_expired); + BOOST_TEST(late_ec == capy::error::canceled); s1.close(); s2.close(); } From 089cdffc2b2629653c10e8573779c00c75c6c328 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:25 +0200 Subject: [PATCH 14/34] fix: report available() and host_name() errors through make_err Three sites built the error code by hand from errno or GetLastError instead of through make_err, bypassing the normalization every other path applies. --- src/corosio/src/host_name.cpp | 17 +++++++++-------- src/corosio/src/local_datagram_socket.cpp | 3 ++- src/corosio/src/local_stream_socket.cpp | 6 ++++-- test/unit/fault/win_faults.cpp | 5 ++--- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/corosio/src/host_name.cpp b/src/corosio/src/host_name.cpp index 41f82e201..37ee623f0 100644 --- a/src/corosio/src/host_name.cpp +++ b/src/corosio/src/host_name.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -32,7 +33,7 @@ host_name() // every mainstream OS's actual cap (Linux 64, macOS/BSD 255). char buf[256]; if (::gethostname(buf, sizeof(buf)) != 0) - return {std::error_code(errno, std::system_category()), {}}; + return {detail::make_err(errno), {}}; // POSIX does not guarantee NUL termination on truncation. if (std::memchr(buf, '\0', sizeof(buf)) == nullptr) @@ -60,7 +61,7 @@ host_name() } if (err != ERROR_MORE_DATA) return { - std::error_code(static_cast(err), std::system_category()), + detail::make_err(static_cast(err)), {}}; // On success, GetComputerNameExW rewrites `size` to the count @@ -69,8 +70,8 @@ host_name() if (!::GetComputerNameExW( ComputerNameDnsHostname, wide.data(), &size)) return { - std::error_code( - static_cast(::GetLastError()), std::system_category()), + detail::make_err( + static_cast(::GetLastError())), {}}; wide.resize(size); @@ -79,8 +80,8 @@ host_name() nullptr, 0, nullptr, nullptr); if (needed <= 0) return { - std::error_code( - static_cast(::GetLastError()), std::system_category()), + detail::make_err( + static_cast(::GetLastError())), {}}; std::string out(static_cast(needed), '\0'); @@ -89,8 +90,8 @@ host_name() out.data(), needed, nullptr, nullptr); if (written != needed) return { - std::error_code( - static_cast(::GetLastError()), std::system_category()), + detail::make_err( + static_cast(::GetLastError())), {}}; return {std::error_code{}, std::move(out)}; } diff --git a/src/corosio/src/local_datagram_socket.cpp b/src/corosio/src/local_datagram_socket.cpp index 80d0e12fc..9d9cf734f 100644 --- a/src/corosio/src/local_datagram_socket.cpp +++ b/src/corosio/src/local_datagram_socket.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include @@ -119,7 +120,7 @@ local_datagram_socket::available() const int value = 0; if (::ioctl(native_handle(), FIONREAD, &value) < 0) detail::throw_system_error( - std::error_code(errno, std::system_category()), + detail::make_err(errno), "local_datagram_socket::available"); return static_cast(value); } diff --git a/src/corosio/src/local_stream_socket.cpp b/src/corosio/src/local_stream_socket.cpp index 8bee574a7..a5feab293 100644 --- a/src/corosio/src/local_stream_socket.cpp +++ b/src/corosio/src/local_stream_socket.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #if BOOST_COROSIO_POSIX #include @@ -118,14 +119,15 @@ local_stream_socket::available() const if (::ioctlsocket( static_cast(native_handle()), FIONREAD, &value) != 0) detail::throw_system_error( - std::error_code(::WSAGetLastError(), std::system_category()), + detail::make_err( + static_cast(::WSAGetLastError())), "local_stream_socket::available"); return static_cast(value); #else int value = 0; if (::ioctl(native_handle(), FIONREAD, &value) < 0) detail::throw_system_error( - std::error_code(errno, std::system_category()), + detail::make_err(errno), "local_stream_socket::available"); return static_cast(value); #endif diff --git a/test/unit/fault/win_faults.cpp b/test/unit/fault/win_faults.cpp index 3d77617dc..b339ef7d5 100644 --- a/test/unit/fault/win_faults.cpp +++ b/test/unit/fault/win_faults.cpp @@ -510,9 +510,8 @@ struct win_common_faults io_context ioc(iocp); local_stream_socket a(ioc), b(ioc); BOOST_TEST(!connect_pair(a, b)); - // available() reports the raw Winsock code rather than - // routing it through make_err - // (src/corosio/src/local_stream_socket.cpp:118-123). + // WSAEINVAL is one of the codes make_err passes through, so + // available() reports it as the raw Winsock code either way. fault_scope f(sys::ioctlsocket, WSAEINVAL); expect_system_error( [&]{ std::ignore = a.available(); }, win_err(WSAEINVAL)); From 503fd70eb909217ad5ae6c19e843a4b6e9695a2c Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 01:54:26 +0200 Subject: [PATCH 15/34] fix(io_uring): keep work accounting symmetric for the multishot accept arm The multishot accept arm is deliberately not counted as outstanding work, but the SQ-full path queued it for dispatch like any other op, so the dispatch's work_finished() underflowed the count and run() never returned. An uncounted op that cannot be submitted is no longer queued; the acceptor records the failure, completes every parked accept with EAGAIN, and a later listen() re-arms it. Which of the two answers an op needs is a property of its call site, not state the op has to carry, so the submission helper says it in its name rather than reading a bit: io_uring_submit_op for the ops a work_started() paid for, which answer a full ring through their own EAGAIN completion and so report nothing, and io_uring_try_submit_op for the ones nothing counted, which hand the refusal back. That leaves the invariant to the type system instead of to a flag every io_uring op would carry and any of them could set by mistake, and the ops keep the layout they had. --- doc/error-handling-rulebook.md | 14 +- .../io_uring/io_uring_multishot_acceptor.hpp | 125 ++++++++++++++++-- .../detail/io_uring/io_uring_socket_ops.hpp | 114 +++++++++++++--- test/unit/fault/uring_faults.cpp | 84 ++++++++++++ 4 files changed, 306 insertions(+), 31 deletions(-) diff --git a/doc/error-handling-rulebook.md b/doc/error-handling-rulebook.md index a2a61dc43..077713e39 100644 --- a/doc/error-handling-rulebook.md +++ b/doc/error-handling-rulebook.md @@ -139,6 +139,14 @@ second channel: - `tls_context` setters record configuration that is applied when a handshake first configures the engine; application failures surface through that handshake's completion. +- A failure with no operation to attach to is latched on the object + and pre-answers the operations that follow, until the step it + belongs to succeeds again: a multishot arming the ring never took + reports to every accept until the next arming clears it. +- A completion queued into a scheduler that spends a + `work_finished()` on everything it dispatches needs a matching + `work_started()`. An operation nothing counted reports through its + owner's channel instead of the completion queue. ## 6. Attributes and Spelling @@ -173,8 +181,10 @@ second channel: `no_such_device_or_address` (`corosio::connect` with no viable candidate), `resource_unavailable_try_again` (io_uring submission queue - exhausted, for a submitted op and for the signal reader alike; and a - polling thread the system would not start). + exhausted, for a submitted op, for the signal reader, and for a + multishot arming that never reached the kernel, latched on the + object until an arming succeeds; and a polling thread the system + would not start). - Portable comparison comes from **normalizing at the boundary**: the Windows `make_err` maps the contracted WSA/Win32 codes to generic-category `errc` values (`WSAEOPNOTSUPP`, `WSAENOTSOCK`, 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 efe2be8f0..768b044c2 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 @@ -114,6 +114,12 @@ class io_uring_multishot_acceptor_base waiter_node* read_wait_ = nullptr; std::unique_ptr multi_op_; bool closing_ = false; + /// Non-zero once an arming failed to reach the kernel (guarded by + /// `mutex_`). Nothing will ever deliver a connection through an + /// SQE the ring never took, so an accept reports this instead of + /// parking on a delivery that cannot come. Cleared by the next + /// arming that does reach the kernel. + int arm_err_ = 0; /// 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 @@ -310,6 +316,7 @@ class io_uring_multishot_acceptor_base w->stop_cb.emplace(token, waiter_canceller{w}); bool was_cancelled = false; + int arm_err = 0; { std::lock_guard lk(mutex_); if (w->cancelled.load(std::memory_order_acquire) || closing_) @@ -318,10 +325,17 @@ class io_uring_multishot_acceptor_base } else if (ready_fds_.empty()) { - w->queued = true; - sched_->work_started(); - read_wait_ = w; - return; + // Readiness here means a future delivery, which a failed + // arming has already ruled out: report it rather than + // wait on one. + arm_err = arm_err_; + if (arm_err == 0) + { + w->queued = true; + sched_->work_started(); + read_wait_ = w; + return; + } } // else: a connection arrived while the callback was armed; // complete as ready below. @@ -333,6 +347,7 @@ class io_uring_multishot_acceptor_base op->h = w->h; op->ex = w->ex; op->ec_out = w->ec_out; + op->err = arm_err; if (was_cancelled) op->cancelled.store(true, std::memory_order_release); delete w; @@ -451,15 +466,28 @@ class io_uring_multishot_acceptor_base 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. + + An arming that failed to reach the kernel is not one of those. + It covers nothing, so a re-listen is the caller's way back and + has to be allowed through. */ bool prepare_listen_arm() noexcept { + bool submitted = true; { std::lock_guard lk(mutex_); - if (multi_op_ && !closing_) + if (multi_op_ && !closing_ && arm_err_ == 0) return false; + // The op behind a failed arming was never handed to the + // ring, so no CQE is owed for it and it is still ours. + submitted = (arm_err_ == 0); } - retire_multishot(); + // Retiring is for an op the kernel still holds: it parks the op + // in the scheduler until a terminal CQE releases it. One that + // was never submitted would wait there for a completion that + // cannot come, so it is reused in place instead. + if (submitted) + retire_multishot(); intrusive_list stale; { std::lock_guard lk(mutex_); @@ -493,17 +521,86 @@ class io_uring_multishot_acceptor_base // Reuse the existing op (re-arm path). Reset peer scratch // 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. + // from an arming that has since been torn down. `res` is + // one of those: an arming that failed to submit left + // -EAGAIN there, and the reader of `res` cannot tell a + // result the kernel wrote from one it did not. multi_op_->peer_storage = sockaddr_storage{}; multi_op_->peer_len = sizeof(sockaddr_storage); + multi_op_->res = 0; multi_op_->listen_fd = fd_; multi_op_->impl_ptr = this->shared_from_this(); } auto* op = multi_op_.get(); - io_uring_submit_op(*sched_, op); // Deliberately no work_started(): the multishot SQE is a persistent // internal mechanism. User-visible work is tracked per-accept call. + // The try_ spelling is what says so: it keeps a failed submission + // off the scheduler's completion queue, which spends a + // work_finished() on everything it dispatches. + if (io_uring_try_submit_op(*sched_, op)) + { + std::lock_guard lk(mutex_); + arm_err_ = 0; + return; + } + fail_arm(EAGAIN); + } + + /** Report an arming that never reached the kernel. + + No CQE can arrive for an SQE the ring never took, so an accept + parked on this acceptor would park for good. The error is + remembered for the accepts still to come and delivered now to + whoever is already parked, which is the same EAGAIN every other + op reports when the SQ stays full. + + @param err The code to report to the accepts this arming can no + longer serve. + */ + void fail_arm(int err) noexcept + { + intrusive_list claimed; + { + std::lock_guard lk(mutex_); + arm_err_ = err; + // Claim each waiter the way a delivery does. One the + // canceller already claimed belongs to it: cancel_waiter + // is waiting on this mutex to unlink the node itself, so + // it has to still be in the list when it gets in. + intrusive_list keep; + while (auto* w = waiters_.pop_front()) + { + if (!w->cancelled.exchange(true, std::memory_order_acq_rel)) + claimed.push_back(w); + else + keep.push_back(w); + } + while (auto* w = keep.pop_front()) + waiters_.push_back(w); + if (read_wait_ && + !read_wait_->cancelled.exchange( + true, std::memory_order_acq_rel)) + { + claimed.push_back(read_wait_); + read_wait_ = nullptr; + } + } + + while (auto* w = claimed.pop_front()) + { + w->stop_cb.reset(); + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept arming 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; + op->impl_out = w->impl_out; + op->err = err; + delete w; + sched_->post(op); + sched_->work_finished(); // balance the waiter's work_started + } } /// Pull a parked fd or queue a waiter — used by Derived::accept(). @@ -621,6 +718,18 @@ class io_uring_multishot_acceptor_base ready_op->peer_len = r->peer_len; delete r; } + else if (arm_err_ != 0) + { + // No arming reached the kernel, so no CQE will deliver + // a connection: parking here would park for good. + // NOLINTNEXTLINE(bugprone-unhandled-exception-at-new) — noexcept accept path: OOM => std::terminate is the intended behavior + ready_op = new uring_accept_op(); + ready_op->h = h; + ready_op->ex = ex; + ready_op->ec_out = ec; + ready_op->impl_out = impl_out; + ready_op->err = arm_err_; + } else { w->queued = true; diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp index 3b552de12..16e9c1d2b 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp @@ -427,27 +427,28 @@ struct uring_connect_op : io_uring_op } }; -/** Submit an `io_uring_op` whose `prep_func` is set. +/** Submit an `io_uring_op`, reporting an SQ that stayed full. - Acquires the ring mutex, prepares the SQE, and (under the same - mutex) CAS-sets `submit_op_posted_`. The first submitter of a - batch wins the CAS and posts the scheduler's `submit_sqes_op`, - which later flushes all queued SQEs in a single - `io_uring_submit_and_get_events` call and drains any ready CQEs. - Subsequent submitters in the same batch piggyback — their SQEs - sit in the user-space SQ ring until that op dispatches. - - On SQ-ring exhaustion (after one flush retry), completes the op - with `EAGAIN` and queues it so its handler dispatches on the next - `do_one` cycle, exactly as if the kernel had returned that error. + The body behind @ref io_uring_submit_op and + @ref io_uring_try_submit_op; `counted` selects which of the two + answers an exhausted SQ ring gets. @pre `op->prep_func != nullptr`. @par Exception Safety Nothrow. + + @param sched The scheduler owning the ring. + @param op The operation to submit. + @param counted True when a `work_started()` backs this op, so its + completion may ride the scheduler's queue. + + @return `false` when the SQ stayed full and the op was left for the + caller to report; `true` otherwise. */ -inline void -io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept +inline bool +io_uring_do_submit_op( + io_uring_scheduler& sched, io_uring_op* op, bool counted) noexcept { sched.lazy_init_ring(); @@ -471,16 +472,21 @@ io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept // to ec_out is overwritten on the way out, and a res left at // zero reads as end-of-file, a zero-byte write, or a // successful connect that never happened. Queue the op as - // completed so do_one dispatches the handler. The caller's - // work_started() already counted this op, with one exception: - // the multishot accept arm deliberately counts nothing, so a - // failure here spends a work_finished() it never matched. Its - // handler is a no-op, which makes that an accounting slip and - // not a use-after-free. (CAS path is not entered here.) + // completed so do_one dispatches the handler; the caller's + // work_started() pays for the work_finished() do_one spends + // on it. (CAS path is not entered here.) op->res = -EAGAIN; + if (!counted) + { + // Nothing counted this op, so queueing it would spend a + // work_finished() the context never owed and drive + // outstanding_work_ below what is really outstanding. + // The owner reports the failure instead. + return false; + } typename io_uring_scheduler::lock_type lock(sched.dispatch_mutex()); sched.push_completed_locked(op); - return; + return true; } op->prep_func(op, sqe); @@ -505,6 +511,72 @@ io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept // Flush is deferred to submit_sqes_op; post() owns the wake. sched.post(&sched.submit_op_ref()); } + return true; +} + +/** Submit an `io_uring_op` a `work_started()` already paid for. + + Acquires the ring mutex, prepares the SQE, and (under the same + mutex) CAS-sets `submit_op_posted_`. The first submitter of a + batch wins the CAS and posts the scheduler's `submit_sqes_op`, + which later flushes all queued SQEs in a single + `io_uring_submit_and_get_events` call and drains any ready CQEs. + Subsequent submitters in the same batch piggyback — their SQEs + sit in the user-space SQ ring until that op dispatches. + + On SQ-ring exhaustion (after one flush retry), completes the op + with `EAGAIN` and queues it so its handler dispatches on the next + `do_one` cycle, exactly as if the kernel had returned that error. + That is why this spelling reports nothing: the failure reaches the + caller as the operation's own `EAGAIN` completion. + + @pre `op->prep_func != nullptr`. + @pre A `work_started()` backs this op, so the `work_finished()` the + scheduler spends on everything it dispatches is owed. + + @par Exception Safety + Nothrow. + + @param sched The scheduler owning the ring. + @param op The operation to submit. + + @see io_uring_try_submit_op +*/ +inline void +io_uring_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept +{ + // The result is true by construction: a counted op's SQ-full path + // queues the op and answers through its own completion. + io_uring_do_submit_op(sched, op, true); +} + +/** Submit an `io_uring_op` nothing counted, reporting a full SQ. + + The scheduler spends a `work_finished()` on everything it + dispatches out of `completed_ops_`, so an op no `work_started()` + backs cannot be completed through that queue: doing so drives + `outstanding_work_` below what is really outstanding. This spelling + hands an SQ that stayed full back to the owner instead, which + reports it through its own channel — the multishot accept arm + latches it and answers the accepts it can no longer serve. + + @pre `op->prep_func != nullptr`. + + @par Exception Safety + Nothrow. + + @param sched The scheduler owning the ring. + @param op The operation to submit. + + @return True when the SQE was prepared; false when the SQ stayed + full after one flush and the caller owns the failure. + + @see io_uring_submit_op +*/ +[[nodiscard]] inline bool +io_uring_try_submit_op(io_uring_scheduler& sched, io_uring_op* op) noexcept +{ + return io_uring_do_submit_op(sched, op, false); } /** Readiness wait via `IORING_OP_POLL_ADD`. diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp index 527516beb..145162002 100644 --- a/test/unit/fault/uring_faults.cpp +++ b/test/unit/fault/uring_faults.cpp @@ -605,6 +605,89 @@ struct uring_faults ::unlink(rf_path.c_str()); } + /* The multishot accept arm is the one submission nothing counted. + + Every other op is paid for by a `work_started()` before it is + submitted, which is what lets the SQ-full path queue it as + completed: the run loop spends a `work_finished()` on everything + it dispatches. The arm is a persistent internal mechanism with no + such credit, so queueing it would spend one the context never + owed. Its failure comes back through the acceptor instead, which + is also the only channel that can say anything at all: an arm the + ring never took produces no CQE, so an accept that parked on a + delivery would park for good. + */ + void testAcceptorArmSqFull() + { + std::optional f, g; + f.emplace(sys::uring_sqe_full, 0); + g.emplace(sys::io_uring_submit_and_get_events, EBADF); + io_context ioc(io_uring); + // The listen inside the constructor is what arms the multishot + // accept, and it finds the one SQE already spent on the wakeup + // poll. + tcp_acceptor acc(ioc, uring_loopback()); + tcp_socket s(ioc); + std::error_code aec, wec; + auto body = [&]() -> capy::task<> + { + { + auto [ec] = co_await acc.accept(s); + aec = ec; + } + { + // Readiness on this backend means a future delivery, + // which the failed arm has ruled out just as squarely. + auto [ec] = co_await acc.wait(wait_type::read); + wec = ec; + } + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST(f->fired()); + // The deferred flush is failed too, so the SQ is still full + // when the arm makes its own retry. + BOOST_TEST(g->fired()); + BOOST_TEST(aec == std::errc::resource_unavailable_try_again); + BOOST_TEST(wec == std::errc::resource_unavailable_try_again); + BOOST_TEST(!s.is_open()); + + // A failed arm has to be recoverable, or an acceptor that hit + // a full SQ once would report EAGAIN for the rest of its life: + // the arming it is left holding covers nothing, so a re-listen + // must be allowed to replace it rather than being read as one + // already in place. + f.reset(); + g.reset(); + ioc.restart(); + BOOST_TEST(!acc.listen()); + tcp_socket c(ioc), s2(ioc); + std::error_code cec, aec2; + // The accept goes first and finds nothing queued, so the + // synchronous accept4 answers EAGAIN and the connection can + // only reach it through the arming. That is what makes this an + // assertion about the arming rather than about accept4: with + // the failed arm still counted as one in place, listen() would + // not have replaced it and this accept would report EAGAIN + // instead of parking. + auto accept_body = [&]() -> capy::task<> + { + auto [ec] = co_await acc.accept(s2); + aec2 = ec; + }; + auto client_body = [&]() -> capy::task<> + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + cec = ec; + }; + capy::run_async(ioc.get_executor())(accept_body()); + capy::run_async(ioc.get_executor())(client_body()); + ioc.run(); + BOOST_TEST(!cec); + BOOST_TEST(!aec2); + BOOST_TEST(s2.is_open()); + } + void run() { if(skip_under_valgrind()) @@ -614,6 +697,7 @@ struct uring_faults testSignalReaderSqFull(); testWaitFails(); testAcceptorDrainSubmitFails(); + testAcceptorArmSqFull(); testSqFull(); testConnectCqeRewrite(); testAcceptCqeRewrite(); From 35b877a824288d4f7e2097eb3a7437f738c6fe66 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 04:21:31 +0200 Subject: [PATCH 16/34] test(harness): count(), CQE flag clearing, hook_is_live, high-fd seams, per-process suites Seams the coverage rounds needed and the harness did not have. fault_scope::count() reports the calls an arm has seen, so a test that must reach past the calls the library makes on the way in can read the ordinal off a counting scope instead of hard-coding one. cqe_fault_scope gained an overload that clears CQE flag bits and an fd of -1 that matches on the opcode alone, since IORING_CQE_F_MORE is cleared only by the kernel on descriptors the library never hands out. hook_is_live answers on POSIX as well as Windows, and fd_wall raises every free low descriptor so the kernel's next one lands above FD_SETSIZE. Suites that fault state created once per process get an executable of their own: the two signal suites on POSIX and, on Windows, iocp_dissociate_faults for the NtSetInformationFile lookup that a function-local static caches on the first release(). --- test/unit/fault/CMakeLists.txt | 3 +- test/unit/fault/Jamfile | 6 +- test/unit/fault/fault.hpp | 63 ++++++-- test/unit/fault/fault_arm.cpp | 5 + test/unit/fault/fault_posix.cpp | 94 +++++++++--- test/unit/fault/fault_slot.hpp | 3 + test/unit/fault/fault_test_utils.hpp | 106 ++++++++++++++ test/unit/fault/fault_uring.cpp | 3 +- test/unit/fault/iocp_dissociate_faults.cpp | 87 +++++++++++ test/unit/fault/posix_faults.cpp | 52 +++++-- test/unit/fault/self_test.cpp | 159 ++++++++++++++++++++- 11 files changed, 532 insertions(+), 49 deletions(-) create mode 100644 test/unit/fault/iocp_dissociate_faults.cpp diff --git a/test/unit/fault/CMakeLists.txt b/test/unit/fault/CMakeLists.txt index 59af4c263..97825dc83 100644 --- a/test/unit/fault/CMakeLists.txt +++ b/test/unit/fault/CMakeLists.txt @@ -43,7 +43,8 @@ if(WIN32) ${CMAKE_CURRENT_SOURCE_DIR}/fault_win.cpp ${CMAKE_CURRENT_SOURCE_DIR}/self_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/win_faults.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/iocp_faults.cpp) + ${CMAKE_CURRENT_SOURCE_DIR}/iocp_faults.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/iocp_dissociate_faults.cpp) else() list(FILTER FAULT_FILES EXCLUDE REGEX "fault_win\\.cpp$") if(NOT BOOST_COROSIO_HAVE_LIBURING) diff --git a/test/unit/fault/Jamfile b/test/unit/fault/Jamfile index 57c5b9666..562280eff 100644 --- a/test/unit/fault/Jamfile +++ b/test/unit/fault/Jamfile @@ -50,13 +50,17 @@ project boost/corosio/test/unit/fault # Darwin and FreeBSD drop only epoll_faults.cpp and uring_faults.cpp, # which have no counterpart there. # fault_arm.cpp holds the arm model every hook translation unit shares. +# The two signal sources and iocp_dissociate_faults.cpp are separate +# because the state they fault is created once per process: b2 builds +# one executable per source, which is the isolation they need. local hooks = fault_arm.cpp fault_posix.cpp ; local tests ; local uring-hook ; if [ os.name ] = NT { hooks = fault_arm.cpp fault_win.cpp ; - tests = self_test.cpp win_faults.cpp iocp_faults.cpp ; + tests = self_test.cpp win_faults.cpp iocp_faults.cpp + iocp_dissociate_faults.cpp ; } else if [ os.name ] = MACOSX { diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp index 2fbaacb8b..f0698185d 100644 --- a/test/unit/fault/fault.hpp +++ b/test/unit/fault/fault.hpp @@ -153,6 +153,19 @@ class fault_scope /// Return true once the armed call has been intercepted. bool fired() const noexcept; + /** Return how many calls to the armed symbol this arm has counted. + + Every live arm watching a symbol counts each call to it, so this + is the arm's own view of the call ordinal an `nth` selects. A + test that has to reach past calls the library makes on the way + in can arm a counting scope first and read the number off it + instead of hard-coding one. + + @return Calls seen since the scope was constructed, including + the one that fired. + */ + unsigned count() const noexcept; + fault_scope(fault_scope const&) = delete; fault_scope& operator=(fault_scope const&) = delete; @@ -171,8 +184,10 @@ class fault_scope Matches the first unsubmitted SQE whose `fd` and `opcode` (`IORING_OP_*`) equal the arguments, remembers its `user_data`, and overwrites `res` on the CQE carrying that `user_data` when it - becomes visible. Only meaningful with the io_uring backend; the - scope is inert on the reactor backends. + becomes visible. An `fd` of -1 matches on the opcode alone, for + the polls the library arms on descriptors it never hands out. + Only meaningful with the io_uring backend; the scope is inert on + the reactor backends. */ class cqe_fault_scope { @@ -182,12 +197,28 @@ class cqe_fault_scope /** Construct a scope that rewrites the matched CQE's `res`. - @param fd The descriptor the SQE was prepared on. + @param fd The descriptor the SQE was prepared on, or -1 to + match on `opcode` alone. @param opcode The `IORING_OP_*` the SQE carries. @param res The value to write into the CQE's `res`. */ cqe_fault_scope(int fd, int opcode, int res); + /** Construct a scope that also clears `flags_to_clear` on the CQE. + + The multishot re-arm paths key off `IORING_CQE_F_MORE`, which + the kernel clears only when it terminates the multishot; there + is no way to provoke that from userspace, so the bit is cleared + here instead. + + @param fd The descriptor the SQE was prepared on, or -1 to + match on `opcode` alone. + @param opcode The `IORING_OP_*` the SQE carries. + @param res The value to write into the CQE's `res`. + @param flags_to_clear Bits to clear in the CQE's `flags`. + */ + cqe_fault_scope(int fd, int opcode, int res, unsigned flags_to_clear); + /// Return true once a CQE has been rewritten. bool fired() const noexcept; @@ -233,15 +264,23 @@ class completion_fault_scope a program imports is decided when it is linked: a name no module references has no thunk to patch and no arm on it will ever fire. The harness reports those at startup; a test asks here rather than - driving a hook that cannot fire. Windows only; on other platforms - nothing defines this. - - @param which The entry point to ask about. The four reached through - a pointer the OS hands out (`AcceptEx`, `ConnectEx`, - `NtSetInformationFile`, `NtFlushBuffersFileEx`) answer for the - hook that substitutes that pointer. - - @return `true` if a hook for `which` is installed in some module. + driving a hook that cannot fire. + + On POSIX the answer comes from the census: `which` has a shadow on + this platform, and — in a shared build — the readback found the + loader binding the library's call to it. A symbol this platform + does not spell (`kevent` on Linux, `accept4` on Darwin) answers + false, which is what lets one portable test skip loudly instead of + asserting `fired()` on an arm that can never fire. + + @param which The entry point to ask about. On Windows the four + reached through a pointer the OS hands out (`AcceptEx`, + `ConnectEx`, `NtSetInformationFile`, `NtFlushBuffersFileEx`) + answer for the hook that substitutes that pointer; + `uring_sqe_full` is not a symbol and answers for the liburing + shadows it works through. + + @return `true` if a hook for `which` is installed and reachable. */ bool hook_is_live(sys which) noexcept; diff --git a/test/unit/fault/fault_arm.cpp b/test/unit/fault/fault_arm.cpp index 58494309d..8b907efae 100644 --- a/test/unit/fault/fault_arm.cpp +++ b/test/unit/fault/fault_arm.cpp @@ -216,4 +216,9 @@ bool fault_scope::fired() const noexcept return global_ ? global_storage.fired : tls_arms.arms[idx_].fired; } +unsigned fault_scope::count() const noexcept +{ + return global_ ? global_storage.seen : tls_arms.arms[idx_].seen; +} + } // boost::corosio::test::fault diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp index ba7d272cd..1589f6df0 100644 --- a/test/unit/fault/fault_posix.cpp +++ b/test/unit/fault/fault_posix.cpp @@ -105,12 +105,19 @@ int truncate_iov(iovec const* in, int n, std::size_t count, iovec* out) noexcept } // namespace cqe_fault_scope::cqe_fault_scope(int fd, int opcode, int res) + : cqe_fault_scope(fd, opcode, res, 0u) +{ +} + +cqe_fault_scope::cqe_fault_scope(int fd, int opcode, int res, + unsigned flags_to_clear) { claim_completion_slot(tls_cqe, "cqe_fault_scope: a completion fault is already armed on this thread"); tls_cqe.fd = fd; tls_cqe.opcode = opcode; tls_cqe.res = res; + tls_cqe.flags_clear = flags_to_clear; } cqe_fault_scope::~cqe_fault_scope() @@ -620,9 +627,19 @@ struct census_entry { char const* name; void const* hook; + // The enumerator a test arms to reach this shadow, or count_ for a + // second spelling of a symbol that already has one. + sys id; }; -#define COROSIO_FAULT_CENSUS(name) { #name, reinterpret_cast(&::name) } +#define COROSIO_FAULT_CENSUS(name) \ + { #name, reinterpret_cast(&::name), sys::name } + +// A second spelling (the glibc fortify wrappers, the Darwin `$` +// suffixes) shares the arm of the name it wraps, so it carries no +// enumerator of its own. +#define COROSIO_FAULT_CENSUS_ALIAS(name) \ + { #name, reinterpret_cast(&::name), sys::count_ } } // namespace @@ -680,19 +697,21 @@ namespace { COROSIO_FAULT_CENSUS(epoll_create1), COROSIO_FAULT_CENSUS(epoll_ctl), COROSIO_FAULT_CENSUS(epoll_wait), COROSIO_FAULT_CENSUS(eventfd), COROSIO_FAULT_CENSUS(timerfd_create), COROSIO_FAULT_CENSUS(timerfd_settime), - COROSIO_FAULT_CENSUS(__read_chk), COROSIO_FAULT_CENSUS(__recv_chk), - COROSIO_FAULT_CENSUS(__recvfrom_chk), COROSIO_FAULT_CENSUS(__poll_chk), - COROSIO_FAULT_CENSUS(__pread64_chk), - COROSIO_FAULT_CENSUS(__open_2), COROSIO_FAULT_CENSUS(__gethostname_chk), + COROSIO_FAULT_CENSUS_ALIAS(__read_chk), COROSIO_FAULT_CENSUS_ALIAS(__recv_chk), + COROSIO_FAULT_CENSUS_ALIAS(__recvfrom_chk), COROSIO_FAULT_CENSUS_ALIAS(__poll_chk), + COROSIO_FAULT_CENSUS_ALIAS(__pread64_chk), + COROSIO_FAULT_CENSUS_ALIAS(__open_2), COROSIO_FAULT_CENSUS_ALIAS(__gethostname_chk), #endif #if defined(__APPLE__) || defined(__FreeBSD__) COROSIO_FAULT_CENSUS(writev), COROSIO_FAULT_CENSUS(kqueue), COROSIO_FAULT_CENSUS(kevent), #endif #if defined(__APPLE__) - { "select", reinterpret_cast(&::corosio_fault_select) }, + { "select", reinterpret_cast(&::corosio_fault_select), + sys::select }, { "select$DARWIN_EXTSN", - reinterpret_cast(&::corosio_fault_select_extsn) }, + reinterpret_cast(&::corosio_fault_select_extsn), + sys::count_ }, #endif #if BOOST_COROSIO_HAVE_LIBURING COROSIO_FAULT_CENSUS(io_uring_queue_init_params), @@ -704,6 +723,13 @@ namespace { #endif }; +constexpr std::size_t census_count = sizeof(census) / sizeof(census[0]); + +// What the readback below concluded, per census entry: whether a call +// the library makes actually lands in the shadow. Written once during +// static initialisation, read by hook_is_live. +bool census_live[census_count]; + // Alias entries name a second spelling of a symbol the library may or // may not have been built to call: the glibc fortify wrappers and the // Darwin `$` suffixes. @@ -965,28 +991,41 @@ void interpose_corosio_dylib() noexcept // binding has to be installed here first. int const readback = [] { + // A static build resolved the calls at link time, so every shadow + // this platform has is reached by construction. + for(auto& live : census_live) + live = true; if(!corosio_is_shared()) return 0; #if defined(__APPLE__) interpose_corosio_dylib(); + // It dies unless every non-alias name was rebound, so surviving it + // settles those; the aliases it skipped bind nothing. + for(std::size_t i = 0; i < census_count; ++i) + census_live[i] = !is_alias_entry(census[i].name); #else bool ok = true; - for(auto const& e : census) + for(std::size_t i = 0; i < census_count; ++i) { + auto const& e = census[i]; void* bound = ::dlsym(RTLD_DEFAULT, e.name); - // The linker exports an executable symbol into .dynsym only - // when a linked .so references it; a mismatch here just means - // the library wasn't built to call this alias. A genuinely - // broken interposition (-Bsymbolic, -fno-plt) fails on the - // plain census names first, not here. - if(bound != e.hook && is_alias_entry(e.name)) + census_live[i] = (bound == e.hook); + if(bound == e.hook) continue; - if(bound != e.hook) - { - std::fprintf(stderr, "fault harness: %s is bound to %p, hook is %p\n", - e.name, bound, e.hook); - ok = false; - } + // A null binding means this libc does not expose the name as a + // dynamic symbol at all: an old-glibc inline like `fstat` that + // redirects to `__fxstat`, so the library never reaches it + // through a PLT slot and the shadow is simply inapplicable. An + // alias is likewise a name the library was not built to call. + // Neither is a broken interposition, so leave the entry not live. + if(bound == nullptr || is_alias_entry(e.name)) + continue; + // A non-null binding to something other than our hook is the real + // failure: the call resolved to the actual libc function, + // bypassing the shadow (-Bsymbolic, -fno-plt). + std::fprintf(stderr, "fault harness: %s is bound to %p, hook is %p\n", + e.name, bound, e.hook); + ok = false; } if(!ok) die("fault harness: shadows are not interposing libboost_corosio.so"); @@ -995,4 +1034,19 @@ int const readback = [] }(); } // namespace + +bool hook_is_live(sys which) noexcept +{ + // Not a symbol: it works by clamping the ring liburing's own + // shadows drive, so it lives exactly when they do. + if(which == sys::uring_sqe_full) + which = sys::io_uring_submit; + for(std::size_t i = 0; i < census_count; ++i) + { + if(census[i].id == which && census_live[i]) + return true; + } + return false; +} + } // boost::corosio::test::fault diff --git a/test/unit/fault/fault_slot.hpp b/test/unit/fault/fault_slot.hpp index dcafc966b..bac7ea24b 100644 --- a/test/unit/fault/fault_slot.hpp +++ b/test/unit/fault/fault_slot.hpp @@ -88,9 +88,12 @@ void* real_symbol(char const* name) noexcept; // pair an SQ-full fault with a completion rewrite. struct cqe_slot { + // -1 matches any fd: the multishot polls the harness has to reach + // are armed on descriptors the library never hands out. int fd = -1; int opcode = -1; int res = 0; + unsigned flags_clear = 0; unsigned long long user_data = 0; bool have_user_data = false; bool fired = false; diff --git a/test/unit/fault/fault_test_utils.hpp b/test/unit/fault/fault_test_utils.hpp index e36bf2342..ff824a3bb 100644 --- a/test/unit/fault/fault_test_utils.hpp +++ b/test/unit/fault/fault_test_utils.hpp @@ -24,6 +24,7 @@ #include #include #include +#include #if defined(_WIN32) #ifndef WIN32_LEAN_AND_MEAN @@ -37,6 +38,8 @@ #else #include #include +#include +#include #include #include #endif @@ -251,6 +254,109 @@ inline void skip_dead_hook(char const* name) "test that arms it\n", name); } +#if !defined(_WIN32) + +// Raise the soft descriptor limit to `want` if it is lower, so a +// descriptor numbered at or above FD_SETSIZE can exist at all. The +// raise is not undone: the descriptors it permits outlive the call +// that asked for it, and lowering the limit under them is what would +// be surprising. +inline bool raise_fd_limit(rlim_t want) +{ + rlimit rl{}; + if(::getrlimit(RLIMIT_NOFILE, &rl) != 0) + return false; + if(rl.rlim_cur >= want) + return true; + if(rl.rlim_max != RLIM_INFINITY && rl.rlim_max < want) + return false; + rl.rlim_cur = want; + return ::setrlimit(RLIMIT_NOFILE, &rl) == 0; +} + +// Report a descriptor table this process is not allowed to grow. Reads +// like skip_dead_hook: the run had a reason not to take the coverage, +// and the log has to say so rather than pass silently. +inline void skip_no_high_fd(char const* what) +{ + std::fprintf(stderr, + "fault harness: this process cannot hold a descriptor at or above " + "FD_SETSIZE; skipping %s\n", what); +} + +// Duplicate `fd` onto a descriptor number above FD_SETSIZE. Returns -1 +// when the limit forbids it, which the caller reports through +// skip_no_high_fd. The select backend rejects such a descriptor rather +// than letting FD_SET clobber unrelated memory, and that rejection is +// what the assign tests are after. +inline int dup_above_fd_setsize(int fd) +{ + constexpr int target = FD_SETSIZE + 8; + if(!raise_fd_limit(static_cast(target) + 8)) + return -1; + if(::dup2(fd, target) != target) + return -1; + return target; +} + +/* Hold every free descriptor number below FD_SETSIZE. + + While one of these is alive the kernel has no low number left to + hand out, so the next socket, pipe or accepted connection lands at + or above FD_SETSIZE. That is the only way to watch the select + backend reject a descriptor it cannot represent, since a descriptor + number is not something a caller chooses. + + Construct it after the io_context: the select scheduler's own + self-pipe has to be representable too. +*/ +class fd_wall +{ +public: + ~fd_wall() + { + for(auto it = held_.rbegin(); it != held_.rend(); ++it) + ::close(*it); + } + + fd_wall() + { + if(!raise_fd_limit(FD_SETSIZE + 64)) + return; + // /dev/null rather than a dup of 0: a test runner may hand the + // process a closed or non-duplicable stdin. + int const seed = ::open("/dev/null", O_RDONLY | O_CLOEXEC); + if(seed < 0) + return; + held_.push_back(seed); + for(;;) + { + int const fd = ::fcntl(seed, F_DUPFD_CLOEXEC, 0); + if(fd < 0) + return; + held_.push_back(fd); + if(fd >= FD_SETSIZE - 1) + break; + } + raised_ = true; + } + + /// Return true if the next descriptor the process opens lands above. + bool ok() const noexcept + { + return raised_; + } + + fd_wall(fd_wall const&) = delete; + fd_wall& operator=(fd_wall const&) = delete; + +private: + std::vector held_; + bool raised_ = false; +}; + +#endif + // BOOST_TEST_THROWS accepts any std::system_error, which would pass // even if the library reported an error the fault never injected. // `Expected` is a std::errc where the library normalizes the code and a diff --git a/test/unit/fault/fault_uring.cpp b/test/unit/fault/fault_uring.cpp index d2fd422fb..d8b263602 100644 --- a/test/unit/fault/fault_uring.cpp +++ b/test/unit/fault/fault_uring.cpp @@ -71,7 +71,7 @@ void scan_pending_sqes(io_uring* ring) noexcept for(unsigned i = ring->sq.sqe_head; i != ring->sq.sqe_tail; ++i) { auto const& sqe = ring->sq.sqes[i & ring->sq.ring_mask]; - if(int(sqe.opcode) == c.opcode && sqe.fd == c.fd) + if(int(sqe.opcode) == c.opcode && (c.fd < 0 || sqe.fd == c.fd)) { c.user_data = sqe.user_data; c.have_user_data = true; @@ -93,6 +93,7 @@ void rewrite_visible_cqes(io_uring* ring) noexcept if(cqe->user_data == c.user_data) { cqe->res = c.res; + cqe->flags &= ~c.flags_clear; c.fired = true; c.armed = false; return; diff --git a/test/unit/fault/iocp_dissociate_faults.cpp b/test/unit/fault/iocp_dissociate_faults.cpp new file mode 100644 index 000000000..768b81a46 --- /dev/null +++ b/test/unit/fault/iocp_dissociate_faults.cpp @@ -0,0 +1,87 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" +#include "test_utils.hpp" + +#include +#include + +#include + +#if BOOST_COROSIO_HAS_IOCP + +namespace boost::corosio::test::fault { + +/* The lookup behind dissociate_from_iocp, which happens once. + + The NtSetInformationFile pointer lives in a function-local static, + so the module handle it is resolved through is asked for on the + first release() the process makes and never again. That is why this + is a suite of its own: iocp_faults releases a socket too, and + whichever ran first would leave the other testing something it did + not mean to. + + The suite name sorts before every other fault suite, so a + single-process run of the whole executable still meets this test + with the pointer unresolved. Under CTest and b2 each suite is its + own process and the ordering does not arise. +*/ +struct iocp_dissociate_faults +{ + void testReleaseWithoutNtEntryPoint() + { + if(!hook_is_live(sys::GetModuleHandleW)) + { + skip_dead_hook("GetModuleHandleW"); + return; + } + io_context ioc(iocp); + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + native_handle_type h{}; + { + // ntdll refuses to answer, so there is no entry point to + // cache and severing the association is never attempted. + // Armed immediately before the call because nothing else + // on this thread asks for a module handle in between. + fault_scope f(sys::GetModuleHandleW, ERROR_MOD_NOT_FOUND); + h = s.release(); + BOOST_TEST(f.fired()); + } + // Severing the association is best effort: the caller gets a + // working socket whether or not it happened. + BOOST_TEST(!s.is_open()); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + + // The lookup ran once for the process, so a second release + // asks nothing and still hands back its socket. + tcp_socket t(ioc); + BOOST_TEST(!t.open(tcp::v4())); + auto const h2 = t.release(); + BOOST_TEST(!t.is_open()); + BOOST_TEST(native_socket_valid(h2)); + close_native_socket(h2); + } + + void run() + { + testReleaseWithoutNtEntryPoint(); + } +}; + +TEST_SUITE(iocp_dissociate_faults, "boost.corosio.fault.dissociate"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/posix_faults.cpp b/test/unit/fault/posix_faults.cpp index ae87aed14..837cfcab6 100644 --- a/test/unit/fault/posix_faults.cpp +++ b/test/unit/fault/posix_faults.cpp @@ -204,14 +204,23 @@ struct posix_common_faults // opening for append; the ring backend leaves that to O_APPEND. if constexpr(!ring_files) { - int before = open_fds(); - fault_scope f(sys::fstat, EIO); - auto ec = sf.open(path, file_base::write_only | - file_base::create | file_base::append); - BOOST_TEST(f.fired()); - BOOST_TEST(ec == std::errc::io_error); - BOOST_TEST(!sf.is_open()); - BOOST_TEST_EQ(open_fds(), before); + // fstat is unshadowed on pre-2.33 glibc (an inline redirect to + // __fxstat), so the append-offset seed cannot be faulted there. + if(!hook_is_live(sys::fstat)) + { + skip_dead_hook("fstat"); + } + else + { + int before = open_fds(); + fault_scope f(sys::fstat, EIO); + auto ec = sf.open(path, file_base::write_only | + file_base::create | file_base::append); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::io_error); + BOOST_TEST(!sf.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } } ::unlink(path.c_str()); } @@ -222,6 +231,11 @@ struct posix_common_faults auto path = temp_path("sf2"); stream_file sf(ioc); BOOST_TEST(!sf.open(path, file_base::read_write | file_base::create)); + if(!hook_is_live(sys::fstat)) + { + skip_dead_hook("fstat"); + } + else { fault_scope f(sys::fstat, EIO); expect_system_error( @@ -245,11 +259,18 @@ struct posix_common_faults } { constexpr sys seek_end_call = ring_files ? sys::lseek : sys::fstat; - fault_scope f(seek_end_call, EIO); - auto [ec, pos] = sf.seek(0, file_base::seek_end); - BOOST_TEST(f.fired()); - BOOST_TEST(ec == std::errc::io_error); - BOOST_TEST_EQ(pos, 0u); + if(!hook_is_live(seek_end_call)) + { + skip_dead_hook(ring_files ? "lseek" : "fstat"); + } + else + { + fault_scope f(seek_end_call, EIO); + auto [ec, pos] = sf.seek(0, file_base::seek_end); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::io_error); + BOOST_TEST_EQ(pos, 0u); + } } sf.close(); ::unlink(path.c_str()); @@ -331,6 +352,11 @@ struct posix_common_faults BOOST_TEST(f.fired()); } BOOST_TEST(!rf.open(path, file_base::read_write | file_base::create)); + if(!hook_is_live(sys::fstat)) + { + skip_dead_hook("fstat"); + } + else { fault_scope f(sys::fstat, EIO); expect_system_error( diff --git a/test/unit/fault/self_test.cpp b/test/unit/fault/self_test.cpp index 65f70620a..ea7dffc9f 100644 --- a/test/unit/fault/self_test.cpp +++ b/test/unit/fault/self_test.cpp @@ -348,7 +348,14 @@ struct self_test expect(sys::fcntl, [&]{ return ::fcntl(fd, F_GETFL); }); expect(sys::ioctl, [&]{ return ::ioctl(fd, FIONREAD, &one); }); expect(sys::open, [&]{ return ::open("/dev/null", O_RDONLY); }); - expect(sys::fstat, [&]{ return ::fstat(fd, &st); }); + // fstat is an inline redirect to __fxstat on pre-2.33 glibc, so the + // "fstat" symbol has no shadow to arm there: the readback marks it + // not live and the call goes straight to libc. Expect not-live + // rather than asserting a fire that cannot happen. + if(hook_is_live(sys::fstat)) + expect(sys::fstat, [&]{ return ::fstat(fd, &st); }); + else + skip_dead_hook("fstat"); expect(sys::lseek, [&]{ return (long)::lseek(fd, 0, SEEK_SET); }); expect(sys::ftruncate, [&]{ return ::ftruncate(fd, 0); }); expect(sys::fsync, [&]{ return ::fsync(fd); }); @@ -527,6 +534,94 @@ struct self_test } #endif + // `nth` arithmetic in the backend suites has to reach past calls + // the library makes on the way in. count() is what lets a test say + // how many there were instead of hard-coding the number. + void testCountTracksCalls() + { + fault_scope f(sys::socket, EMFILE, 3); + BOOST_TEST_EQ(f.count(), 0u); + int a = ::socket(AF_INET, SOCK_STREAM, 0); + int b = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(f.count(), 2u); + BOOST_TEST(!f.fired()); + BOOST_TEST_EQ(::socket(AF_INET, SOCK_STREAM, 0), -1); + BOOST_TEST_EQ(f.count(), 3u); + BOOST_TEST(f.fired()); + // A spent arm stops counting: it no longer claims calls. + int c = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_EQ(f.count(), 3u); + ::close(a); + ::close(b); + ::close(c); + } + + // Every backend suite asserts fired() on the arms it sets. A symbol + // this platform has no shadow for would fail that assertion with + // nothing to say why, so a portable test asks first. + void testHookIsLiveAnswersPerPlatform() + { + BOOST_TEST(hook_is_live(sys::socket)); + BOOST_TEST(hook_is_live(sys::getsockopt)); + BOOST_TEST(hook_is_live(sys::select)); + // No POSIX shadow spells a Win32 entry point. + BOOST_TEST(!hook_is_live(sys::WSASocketW)); + BOOST_TEST(!hook_is_live(sys::CreateIoCompletionPort)); +#if defined(__linux__) + BOOST_TEST(hook_is_live(sys::epoll_ctl)); + BOOST_TEST(hook_is_live(sys::accept4)); + BOOST_TEST(!hook_is_live(sys::kevent)); +#endif +#if defined(__APPLE__) || defined(__FreeBSD__) + BOOST_TEST(hook_is_live(sys::kevent)); + BOOST_TEST(!hook_is_live(sys::epoll_ctl)); +#endif +#if BOOST_COROSIO_HAVE_LIBURING + BOOST_TEST(hook_is_live(sys::io_uring_submit)); + BOOST_TEST(hook_is_live(sys::uring_sqe_full)); +#else + BOOST_TEST(!hook_is_live(sys::uring_sqe_full)); +#endif + } + + // The select backend rejects descriptors it cannot represent, and a + // descriptor number is not something a caller picks: the tests that + // reach those arms need these two seams to work. + void testHighFdHelpers() + { + int const fd = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(fd >= 0); + int const hi = dup_above_fd_setsize(fd); + if(hi < 0) + { + skip_no_high_fd("the high-descriptor helper self-test"); + ::close(fd); + return; + } + BOOST_TEST(hi >= FD_SETSIZE); + BOOST_TEST_EQ(::fcntl(hi, F_GETFD) == -1, false); + ::close(hi); + ::close(fd); + + { + fd_wall wall; + if(!wall.ok()) + { + skip_no_high_fd("the descriptor-wall self-test"); + return; + } + int const walled = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(walled >= FD_SETSIZE); + ::close(walled); + } + // The wall releases the numbers it held, or every later test + // would run against a table it did not ask for. + int const freed = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST(freed >= 0); + BOOST_TEST(freed < FD_SETSIZE); + ::close(freed); + } + #if BOOST_COROSIO_HAVE_LIBURING void testUringSubmitFails() { @@ -583,6 +678,38 @@ struct self_test ::close(sv[1]); io_uring_queue_exit(&ring); } + + // The multishot re-arm paths key off IORING_CQE_F_MORE, which only + // the kernel clears. Rewriting `res` alone cannot reach them. + void testCqeFlagsCleared() + { + io_uring ring; + io_uring_params p{}; + BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); + int sv[2]; + BOOST_TEST_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0); + { + // fd -1: match on the opcode alone, the way a test reaches + // a poll armed on a descriptor the library never handed out. + cqe_fault_scope c(-1, IORING_OP_POLL_ADD, POLLIN, + IORING_CQE_F_MORE); + auto* sqe = io_uring_get_sqe(&ring); + io_uring_prep_poll_multishot(sqe, sv[1], POLLIN); + io_uring_sqe_set_data64(sqe, 7); + BOOST_TEST_EQ(io_uring_submit(&ring), 1); + BOOST_TEST_EQ(::write(sv[0], "x", 1), 1); + io_uring_cqe* cqe = nullptr; + BOOST_TEST_EQ( + io_uring_wait_cqe_timeout(&ring, &cqe, nullptr), 0); + BOOST_TEST(c.fired()); + BOOST_TEST_EQ(cqe->user_data, 7u); + BOOST_TEST_EQ(cqe->flags & IORING_CQE_F_MORE, 0u); + io_uring_cqe_seen(&ring, cqe); + } + ::close(sv[0]); + ::close(sv[1]); + io_uring_queue_exit(&ring); + } #endif void run() @@ -605,6 +732,9 @@ struct self_test testOnlyMatchingSymbolFires(); testEveryCensusSymbolFails(); testReturningTruncatesAndForwards(); + testCountTracksCalls(); + testHookIsLiveAnswersPerPlatform(); + testHighFdHelpers(); #if defined(__APPLE__) testDarwinSelectAliasReachesHook(); #endif @@ -615,6 +745,7 @@ struct self_test testUringSubmitFails(); testUringSqeFull(); testCqeRewrite(); + testCqeFlagsCleared(); #endif } }; @@ -764,6 +895,31 @@ struct self_test std::ignore = ::closesocket(a); } + // `nth` arithmetic in the backend suites has to reach past calls + // the library makes on the way in. count() is what lets a test say + // how many there were instead of hard-coding the number. The arm + // model is shared with the POSIX harness, so what this pins down + // is that the Windows hooks feed it. + void testCountTracksCalls() + { + fault_scope f(sys::socket, WSAEMFILE, 3); + BOOST_TEST_EQ(f.count(), 0u); + SOCKET a = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + SOCKET b = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST_EQ(f.count(), 2u); + BOOST_TEST(!f.fired()); + BOOST_TEST(::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) == + INVALID_SOCKET); + BOOST_TEST_EQ(f.count(), 3u); + BOOST_TEST(f.fired()); + // A spent arm stops counting: it no longer claims calls. + SOCKET c = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + BOOST_TEST_EQ(f.count(), 3u); + std::ignore = ::closesocket(a); + std::ignore = ::closesocket(b); + std::ignore = ::closesocket(c); + } + void testFiredScopeStaysFired() { fault_scope f(sys::socket, WSAEMFILE); @@ -1319,6 +1475,7 @@ struct self_test winsock_guard guard; testFiresOnNth(); testDisarmsOnScopeExit(); + testCountTracksCalls(); testFiredScopeStaysFired(); testOpenFdsProbeWorks(); testTransparentWhenUnarmed(); From c15e59bdd5dc7c05769e0def59a00bf8068f778e Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 05:18:07 +0200 Subject: [PATCH 17/34] test(harness): flush coverage counters before a forked child exits in_child ends the child with _Exit, which runs no atexit handler, so an instrumented child discarded everything it counted: a branch only the child reached measured as unreached however often the test passed. The child now calls __gcov_dump when the binary has one, found by dlsym at runtime because a weak declaration is refused by ld64 and a weak reference does not extract the archive member anyway. The CMake build forces the entry point in with -u under coverage flags, spelled ___gcov_dump on Mach-O. Boost's coverage script passes --coverage as a raw linkflag rather than on, so the b2 build probes for libgcov by linking against it and only then adds -u together with --export-dynamic-symbol, which the runtime lookup needs on ELF. --- test/unit/fault/CMakeLists.txt | 24 +++++++++++++++++++++++ test/unit/fault/Jamfile | 18 ++++++++++++++++- test/unit/fault/check/gcov_dump.cpp | 17 ++++++++++++++++ test/unit/fault/fault_test_utils.hpp | 29 +++++++++++++++++++++++++++- 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 test/unit/fault/check/gcov_dump.cpp diff --git a/test/unit/fault/CMakeLists.txt b/test/unit/fault/CMakeLists.txt index 97825dc83..afd08c48d 100644 --- a/test/unit/fault/CMakeLists.txt +++ b/test/unit/fault/CMakeLists.txt @@ -64,6 +64,30 @@ target_link_libraries(boost_corosio_fault_tests PRIVATE target_include_directories(boost_corosio_fault_tests PRIVATE . .. ../../../ ../../../src/corosio) +# in_child's forked child looks up libgcov's __gcov_dump and calls it +# before _Exit, but nothing else in the program references that symbol, +# so the archive member holding it is never pulled in and the lookup +# would answer null. Ask the linker for it where the tree is +# instrumented. +# +# The name is the linker's spelling, not C's: Mach-O prefixes every C +# symbol with an underscore. Windows needs none of this: it has no +# fork, so in_child runs the body directly. +# +# The guard reads the flags the coverage jobs actually set. A tree +# instrumented some other way -- a toolchain file, or per-configuration +# flags -- goes undetected, and the cost of that is a forked child's +# counters going unrecorded again. +if(NOT WIN32 AND CMAKE_CXX_FLAGS MATCHES "--coverage|-fprofile-arcs") + if(APPLE) + target_link_options(boost_corosio_fault_tests PRIVATE + "LINKER:-u,___gcov_dump") + else() + target_link_options(boost_corosio_fault_tests PRIVATE + "LINKER:-u,__gcov_dump") + endif() +endif() + if(NOT WIN32) target_link_libraries(boost_corosio_fault_tests PRIVATE ${CMAKE_DL_LIBS}) # Interposition only works if the shadows land in the executable's diff --git a/test/unit/fault/Jamfile b/test/unit/fault/Jamfile index 562280eff..e04f761d5 100644 --- a/test/unit/fault/Jamfile +++ b/test/unit/fault/Jamfile @@ -7,6 +7,7 @@ # Official repository: https://github.com/cppalliance/corosio # +import configure ; import os ; import testing ; @@ -44,6 +45,21 @@ project boost/corosio/test/unit/fault clang-win,norecover:no ; +# A forked child ends with _Exit, so its counters only reach disk if +# the harness can call __gcov_dump itself. --coverage arrives as a raw +# linkflag rather than on, so probe for libgcov by linking +# against it and only then force the entry point into the executable +# and into its dynamic symbol table, where the runtime lookup reads. +exe gcov_dump_check : check/gcov_dump.cpp ; +explicit gcov_dump_check ; +local gcov-dump = [ check-target-builds gcov_dump_check "gcov dump linkable" + : linux:-Wl,-u,__gcov_dump + linux:-Wl,--export-dynamic-symbol=__gcov_dump + freebsd:-Wl,-u,__gcov_dump + freebsd:-Wl,--export-dynamic-symbol=__gcov_dump + darwin:-Wl,-u,___gcov_dump + : ] ; + # The POSIX shadows are written against glibc, Darwin libc and the # FreeBSD libc and the Windows hooks against the PE import table, so # nothing is built anywhere else; the CMake side gates the same way. @@ -86,5 +102,5 @@ else if [ os.name ] = LINUX for local f in $(tests) { - run $(f) $(hooks) : : : $(uring-hook) ; + run $(f) $(hooks) : : : $(uring-hook) $(gcov-dump) ; } diff --git a/test/unit/fault/check/gcov_dump.cpp b/test/unit/fault/check/gcov_dump.cpp new file mode 100644 index 000000000..409b3b9c3 --- /dev/null +++ b/test/unit/fault/check/gcov_dump.cpp @@ -0,0 +1,17 @@ +// +// 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 +// + +// Links only when the build carries --coverage, since nothing else +// provides libgcov's dump entry point. The test runner supplies main. +extern "C" void __gcov_dump(); + +void gcov_dump_check() +{ + __gcov_dump(); +} diff --git a/test/unit/fault/fault_test_utils.hpp b/test/unit/fault/fault_test_utils.hpp index ff824a3bb..8c9a5c2ff 100644 --- a/test/unit/fault/fault_test_utils.hpp +++ b/test/unit/fault/fault_test_utils.hpp @@ -37,6 +37,7 @@ #include #else #include +#include #include #include #include @@ -171,6 +172,28 @@ inline int open_fds() #endif } +// Write out this binary's coverage counters, if it has any. The child +// below ends with _Exit, which runs no atexit handler, so an +// instrumented child would discard everything it counted and every +// branch only it reached would measure as unreached however many times +// the test passed. +// +// Looked up rather than declared. An optional symbol is spelled +// differently by each linker -- ELF takes a weak undefined reference, +// ld64 rejects one outright and does not accept weak_import for a +// symbol no library provides -- and an uninstrumented build has to +// link either way. A lookup leaves nothing undefined at link time and +// answers null where there is no libgcov, which is the same thing the +// weak reference was meant to say. The CMake side is what keeps the +// symbol in an instrumented binary for this to find. +inline void flush_coverage_counters() +{ + static auto const dump = + reinterpret_cast(::dlsym(RTLD_DEFAULT, "__gcov_dump")); + if(dump) + dump(); +} + // Run `body` in a forked child and assert it returned true. Process-wide // state that is created once — the signal self-pipe and its sigaction // handlers — can only be faulted in a fresh process, and installing it @@ -183,7 +206,11 @@ void in_child(F&& body) if(pid < 0) return; if(pid == 0) - std::_Exit(body() ? 0 : 1); + { + bool const ok = body(); + flush_coverage_counters(); + std::_Exit(ok ? 0 : 1); + } int status = 0; ::waitpid(pid, &status, 0); BOOST_TEST(WIFEXITED(status) && WEXITSTATUS(status) == 0); From 22fad0897b4b8a5eefc5c063abc28daa9a68b3fe Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 04:22:32 +0200 Subject: [PATCH 18/34] test(select): descriptor-range rejections and the SIGPIPE guard select() addresses descriptors by bit position in an fd_set, so a number at or above FD_SETSIZE has nowhere to go. The three entry points that can be handed one reject it, and none of those rejections had ever run: a descriptor number is not something a caller picks. The tests adopt a descriptor duplicated above the range and raise a wall over every free low number so open() and accept() land above it. Where the platform defines SO_NOSIGPIPE the backend sets it on every descriptor it creates or accepts and treats a refusal as fatal, since its write() fallback carries no per-call suppression. Both arms check the descriptor the failure path owns was released and that the accept arm reports the option's errno rather than the close()'s. --- test/unit/fault/select_faults.cpp | 192 ++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/test/unit/fault/select_faults.cpp b/test/unit/fault/select_faults.cpp index 7549be054..20076a429 100644 --- a/test/unit/fault/select_faults.cpp +++ b/test/unit/fault/select_faults.cpp @@ -11,6 +11,7 @@ #include "fault_test_utils.hpp" #include "context.hpp" #include "test_suite.hpp" +#include "test_utils.hpp" #include #include @@ -24,6 +25,10 @@ #include #include +#include +#include +#include + #if BOOST_COROSIO_HAS_SELECT namespace boost::corosio::test::fault { @@ -252,6 +257,186 @@ struct select_faults BOOST_TEST(done); } + /* Descriptors this backend cannot represent. + + select() addresses descriptors by bit position in an fd_set, so + a number at or above FD_SETSIZE has nowhere to go and FD_SET on + it would write past the set. Every entry point that can be + handed such a number rejects it instead, and the rejection is + what these three tests drive: adoption (validate_assigned_fd), + creation (set_fd_options) and acceptance (accept_policy). + */ + void testAssignAboveFdSetsize() + { + io_context ioc(select); + int const before = open_fds(); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(static_cast(h) >= 0); + make_native_adoptable(h); + int const high = dup_above_fd_setsize(static_cast(h)); + if(high < 0) + { + skip_no_high_fd("testAssignAboveFdSetsize"); + close_native_socket(h); + return; + } + { + tcp_socket s(ioc); + auto ec = s.assign(static_cast(high)); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + } + { + tcp_acceptor acc(ioc); + auto ec = acc.assign(static_cast(high)); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!acc.is_open()); + } + // The rejection is non-mutating: the caller still owns both. + BOOST_TEST(native_socket_valid( + static_cast(high))); + BOOST_TEST(native_socket_valid(h)); + ::close(high); + close_native_socket(h); + BOOST_TEST_EQ(open_fds(), before); + } + + void testOpenAboveFdSetsize() + { + // The context first: the scheduler's own self-pipe has to be + // representable, and the wall would deny it a number. + io_context ioc(select); + fd_wall wall; + if(!wall.ok()) + { + skip_no_high_fd("testOpenAboveFdSetsize"); + return; + } + tcp_socket s(ioc); + auto ec = s.open(tcp::v4()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!s.is_open()); + tcp_acceptor acc(ioc); + BOOST_TEST(acc.open() == std::errc::too_many_files_open); + BOOST_TEST(!acc.is_open()); + } + + void testAcceptAboveFdSetsize() + { + io_context ioc(select); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + std::error_code aec; + bool skipped = false; + int leaked = 0; + auto body = [&]() -> capy::task<> + { + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + fd_wall wall; + if(!wall.ok()) + { + skipped = true; + co_return; + } + int const before = open_fds(); + auto [ec] = co_await acc.accept(server); + // accept_policy closes the descriptor it cannot represent + // before reporting, so the pending connection is consumed + // and nothing is left behind. + leaked = open_fds() - before; + aec = ec; + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + if(skipped) + { + skip_no_high_fd("testAcceptAboveFdSetsize"); + return; + } + BOOST_TEST(aec == std::errc::invalid_argument); + BOOST_TEST(!server.is_open()); + BOOST_TEST_EQ(leaked, 0); + } + +#ifdef SO_NOSIGPIPE + /* The per-descriptor SIGPIPE guard, where the platform has one. + + This backend is portable rather than Linux-shaped: its write + policy falls back to write(), which carries no per-call flag, so + on a platform that defines SO_NOSIGPIPE the socket-level flag is + the only guard there is and select_traits::set_fd_options + treats a refusal as fatal. Linux has no such option and compiles + both arms away. + */ + void testOpenNoSigPipeFails() + { + io_context ioc(select); + int const before = open_fds(); + { + // SO_NOSIGPIPE is the only setsockopt an AF_INET open + // makes, so the first call is that one. + tcp_socket s(ioc); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto ec = s.open(tcp::v4()); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::no_protocol_option); + BOOST_TEST(!s.is_open()); + } + { + tcp_acceptor acc(ioc); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::no_protocol_option); + BOOST_TEST(!acc.is_open()); + } + // The descriptor exists before the option is refused, so the + // failure path owns closing it. + BOOST_TEST_EQ(open_fds(), before); + } + + void testAcceptNoSigPipeFails() + { + io_context ioc(select); + tcp_acceptor acc(ioc, loopback()); + std::error_code aec; + int leaked = 0; + unsigned calls = 0; + auto body = [&]() -> capy::task<> + { + tcp_socket c(ioc), s(ioc); + { + auto [ec] = co_await c.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + } + int const before = open_fds(); + // Armed after the connect, so the first setsockopt is the + // one accept_policy makes on the accepted descriptor + // (accept_policy::do_accept). + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto [ec] = co_await acc.accept(s); + aec = ec; + calls = f.count(); + leaked = open_fds() - before; + BOOST_TEST(f.fired()); + BOOST_TEST(!s.is_open()); + c.close(); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST_EQ(calls, 1u); + BOOST_TEST(aec == std::errc::no_protocol_option); + // accept_policy closes the descriptor it could not guard, and + // the errno it preserves is the option's, not close()'s. + BOOST_TEST_EQ(leaked, 0); + } +#endif + void run() { if(skip_under_valgrind()) @@ -260,8 +445,15 @@ struct select_faults testOpenFcntlFails(); testAcceptFails(); testAcceptFcntlFails(); +#ifdef SO_NOSIGPIPE + testOpenNoSigPipeFails(); + testAcceptNoSigPipeFails(); +#endif testRunLoopFaults(); testInterruptWriteFails(); + testAssignAboveFdSetsize(); + testOpenAboveFdSetsize(); + testAcceptAboveFdSetsize(); } }; From 775b880e6fcc572ef0c8bb27ccfa69e8aa86674a Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 04:26:30 +0200 Subject: [PATCH 19/34] test(posix): per-process signal suites, connect_pair rollback, local datagram, the shutdown walk The signal service faults state created once per process, the self-pipe and the sigaction table, so those tests run as suites of their own. The rest fill in the plain-syscall legs the posix layer owns: connect_pair's rollback when the second socket cannot be made, the local datagram available() probe and the throwing convenience constructors. The signal service's shutdown walk deletes registrations still parked on a wait. It is reached from a forked child, since an abandoned signal_set leaves its SIGINT registration process-wide, and the test requires the child to have parked something before the walk runs. --- test/unit/fault/Jamfile | 9 +- test/unit/fault/epoll_faults.cpp | 19 -- test/unit/fault/fault_test_utils.hpp | 21 +++ test/unit/fault/posix_faults.cpp | 199 +++++++++++++++----- test/unit/fault/self_test.cpp | 8 +- test/unit/fault/signal_pipe_faults.cpp | 180 ++++++++++++++++++ test/unit/fault/signal_sigaction_faults.cpp | 99 ++++++++++ test/unit/fault/uring_faults.cpp | 51 ----- 8 files changed, 457 insertions(+), 129 deletions(-) create mode 100644 test/unit/fault/signal_pipe_faults.cpp create mode 100644 test/unit/fault/signal_sigaction_faults.cpp diff --git a/test/unit/fault/Jamfile b/test/unit/fault/Jamfile index e04f761d5..0d24b99a0 100644 --- a/test/unit/fault/Jamfile +++ b/test/unit/fault/Jamfile @@ -81,17 +81,20 @@ if [ os.name ] = NT else if [ os.name ] = MACOSX { tests = self_test.cpp posix_faults.cpp select_faults.cpp - kqueue_faults.cpp reactor_faults.cpp ; + kqueue_faults.cpp reactor_faults.cpp + signal_pipe_faults.cpp signal_sigaction_faults.cpp ; } else if [ os.name ] = FREEBSD { tests = self_test.cpp posix_faults.cpp select_faults.cpp - kqueue_faults.cpp reactor_faults.cpp ; + kqueue_faults.cpp reactor_faults.cpp + signal_pipe_faults.cpp signal_sigaction_faults.cpp ; } else if [ os.name ] = LINUX { tests = self_test.cpp posix_faults.cpp select_faults.cpp - epoll_faults.cpp reactor_faults.cpp uring_faults.cpp ; + epoll_faults.cpp reactor_faults.cpp uring_faults.cpp + signal_pipe_faults.cpp signal_sigaction_faults.cpp ; # The io_uring hook includes unconditionally, so it can # only build where the library's own probe found liburing. Without # it uring_faults.cpp compiles away behind BOOST_COROSIO_HAS_IO_URING diff --git a/test/unit/fault/epoll_faults.cpp b/test/unit/fault/epoll_faults.cpp index 860872efc..911bbe11d 100644 --- a/test/unit/fault/epoll_faults.cpp +++ b/test/unit/fault/epoll_faults.cpp @@ -252,24 +252,6 @@ struct epoll_faults BOOST_TEST(done); } - void testSignalReaderRegisterFails() - { - in_child([]{ - io_context ioc(epoll); - signal_set ss(ioc); - std::error_code ec; - bool fired = false; - { - fault_scope f(sys::epoll_ctl, ENOMEM); - ec = ss.add(SIGUSR2); - fired = f.fired(); - } - // Not latched: the next add retries the registration. - return fired && ec == std::errc::not_enough_memory && - !ss.add(SIGUSR2) && !ss.clear(); - }); - } - void run() { if(skip_under_valgrind()) @@ -280,7 +262,6 @@ struct epoll_faults testAcceptFails(); testRunLoopFaults(); testInterruptWriteFails(); - testSignalReaderRegisterFails(); } }; diff --git a/test/unit/fault/fault_test_utils.hpp b/test/unit/fault/fault_test_utils.hpp index 8c9a5c2ff..66e2e1f01 100644 --- a/test/unit/fault/fault_test_utils.hpp +++ b/test/unit/fault/fault_test_utils.hpp @@ -13,6 +13,9 @@ #include "fault.hpp" #include "test_suite.hpp" +#include +#include + #if defined(__FreeBSD__) // real_symbol: the descriptor scan below must not spend a live `fcntl` // arm on its own probing. @@ -47,6 +50,24 @@ namespace boost::corosio::test::fault { +/* The one backend a process-wide suite may use. + + The signal self-pipe and its handlers are created once per process, + so a suite that faults their creation cannot be instantiated per + backend: the first instantiation would install exactly what the rest + were meant to fault. Such a suite picks the platform's native + reactor and names the others explicitly where it needs them. +*/ +#if BOOST_COROSIO_HAS_EPOLL +inline constexpr auto one_backend = corosio::epoll; +#elif BOOST_COROSIO_HAS_KQUEUE +inline constexpr auto one_backend = corosio::kqueue; +#elif BOOST_COROSIO_HAS_IOCP +inline constexpr auto one_backend = corosio::iocp; +#else +inline constexpr auto one_backend = corosio::select; +#endif + #if defined(_WIN32) // The handle count is Windows' answer to the descriptor count: a diff --git a/test/unit/fault/posix_faults.cpp b/test/unit/fault/posix_faults.cpp index 837cfcab6..205294a41 100644 --- a/test/unit/fault/posix_faults.cpp +++ b/test/unit/fault/posix_faults.cpp @@ -16,6 +16,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -163,9 +166,121 @@ struct posix_common_faults BOOST_TEST(ec == std::errc::invalid_argument); BOOST_TEST(!a.is_open() && !b.is_open()); } + // assign() interrogates the descriptor once with + // getsockopt(SO_TYPE), so the ordinal selects which half of + // the pair is refused. Either way both descriptors go back. + for(unsigned nth : {1u, 2u}) + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::getsockopt, EBADF, nth); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), nth); + BOOST_TEST(ec == std::errc::bad_file_descriptor); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + local_datagram_socket a(ioc), b(ioc); + fault_scope f(sys::socketpair, EMFILE); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST(!a.is_open() && !b.is_open()); + } + { + local_datagram_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + } BOOST_TEST_EQ(open_fds(), before); } + void testDatagramAvailableThrows() + { + io_context ioc(Backend); + local_datagram_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + fault_scope f(sys::ioctl, EBADF); + expect_system_error( + [&]{ std::ignore = a.available(); }, + std::errc::bad_file_descriptor); + BOOST_TEST(f.fired()); + BOOST_TEST(a.is_open()); + } + + void testDatagramBindFails() + { + io_context ioc(Backend); + auto path = temp_path("ldb"); + ::unlink(path.c_str()); + local_datagram_socket s(ioc); + BOOST_TEST(!s.open()); + // An already-open socket takes the early return rather than + // replacing the descriptor it holds. + BOOST_TEST(!s.open()); + { + fault_scope f(sys::bind, EACCES); + BOOST_TEST(s.bind(corosio::local_endpoint(path)) == + std::errc::permission_denied); + BOOST_TEST(f.fired()); + BOOST_TEST(s.is_open()); + } + BOOST_TEST(!s.bind(corosio::local_endpoint(path))); + s.close(); + ::unlink(path.c_str()); + } + + /* The convenience constructors report through an exception, since + there is no object yet to hand an error code back on. Each leg + has its own `what`, which is the only way a caller can tell + which step of open/bind/listen refused. + */ + void testAcceptorConstructorThrows() + { + io_context ioc(Backend); + { + int before = open_fds(); + fault_scope f(sys::listen, EADDRINUSE); + expect_system_error( + [&]{ + tcp_acceptor acc( + ioc, endpoint(ipv4_address::loopback(), 0)); + }, + std::errc::address_in_use); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + auto path = temp_path("lsa"); + { + ::unlink(path.c_str()); + int before = open_fds(); + fault_scope f(sys::socket, EMFILE); + expect_system_error( + [&]{ + local_stream_acceptor acc( + ioc, corosio::local_endpoint(path)); + }, + std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + { + // bind() leaves the socket node behind, and a second bind + // to a path that exists fails for its own reason. + ::unlink(path.c_str()); + int before = open_fds(); + fault_scope f(sys::listen, EADDRINUSE); + expect_system_error( + [&]{ + local_stream_acceptor acc( + ioc, corosio::local_endpoint(path)); + }, + std::errc::address_in_use); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(open_fds(), before); + } + ::unlink(path.c_str()); + } + void testAvailableThrows() { io_context ioc(Backend); @@ -430,57 +545,6 @@ struct posix_common_faults ::unlink(path.c_str()); } - void testSignalPipeFails() - { - // The signal pipe is process-wide and created once; these - // faults only fire in a fresh process, so run them in a fork. - in_child([&]{ - io_context ioc(Backend); - signal_set ss(ioc); - fault_scope f(sys::pipe, EMFILE); - auto ec = ss.add(SIGUSR2); - return f.fired() && ec == std::errc::too_many_files_open; - }); - // 1..3 are F_GETFL, F_SETFL and F_SETFD on the read end. - for(unsigned nth : {1u, 2u, 3u}) - { - in_child([&]{ - io_context ioc(Backend); - signal_set ss(ioc); - int before = open_fds(); - fault_scope f(sys::fcntl, EINVAL, nth); - auto ec = ss.add(SIGUSR2); - return f.fired() && ec == std::errc::invalid_argument && - open_fds() == before; - }); - } - in_child([&]{ - io_context ioc(Backend); - signal_set ss(ioc); - fault_scope f(sys::sigaction, EINVAL); - auto ec = ss.add(SIGUSR2); - return f.fired() && ec == std::errc::invalid_argument; - }); - in_child([&]{ - io_context ioc(Backend); - signal_set ss(ioc); - if(ss.add(SIGUSR2)) - return false; - fault_scope f(sys::sigaction, EINVAL); - auto ec = ss.remove(SIGUSR2); - return f.fired() && ec == std::errc::invalid_argument; - }); - in_child([&]{ - io_context ioc(Backend); - signal_set ss(ioc); - if(ss.add(SIGUSR2)) - return false; - fault_scope f(sys::sigaction, EINVAL); - auto ec = ss.clear(); - return f.fired() && ec == std::errc::invalid_argument; - }); - } - void testResolverFails() { io_context ioc(Backend); @@ -510,6 +574,34 @@ struct posix_common_faults BOOST_TEST(rec == std::errc::io_error); } + // The signal service's shutdown walks the implementations it still + // owns, deleting each set and the registrations hanging off it. A + // signal set that outlives its io_context is the only way to reach + // that walk, and the SIGINT registration it leaves behind stays in + // the process signal table and fails every later add() of the same + // signal -- so the whole thing happens in a child that dies with it. + void testSignalTeardownWalk() + { + in_child([]{ + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + signal_set sig(ioc, SIGINT); + std::ignore = co_await sig.wait(); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // Exactly one handler, and it has to be the coroutine + // start: a zero here would leave nothing parked and the + // walk with nothing to reclaim. + if(ioc.run_one() != 1) + return false; + } + return !resumed; + }); + } + void run() { if(skip_under_valgrind()) @@ -520,14 +612,17 @@ struct posix_common_faults testGetOptionFails(); testAssignValidateFails(); testConnectPairFails(); + testDatagramAvailableThrows(); + testDatagramBindFails(); + testAcceptorConstructorThrows(); testAvailableThrows(); testHostNameFails(); testStreamFileOpenFails(); testStreamFileSyncOps(); testStreamFileIoFails(); testRandomAccessFileFails(); - testSignalPipeFails(); testResolverFails(); + testSignalTeardownWalk(); } }; diff --git a/test/unit/fault/self_test.cpp b/test/unit/fault/self_test.cpp index ea7dffc9f..8ed1b5800 100644 --- a/test/unit/fault/self_test.cpp +++ b/test/unit/fault/self_test.cpp @@ -625,7 +625,7 @@ struct self_test #if BOOST_COROSIO_HAVE_LIBURING void testUringSubmitFails() { - io_uring ring; + ::io_uring ring; io_uring_params p{}; BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); { @@ -640,7 +640,7 @@ struct self_test void testUringSqeFull() { fault_scope f(sys::uring_sqe_full, 0); - io_uring ring; + ::io_uring ring; io_uring_params p{}; BOOST_TEST_EQ(io_uring_queue_init_params(64, &ring, &p), 0); // liburing may round the clamped entry count up to its own @@ -656,7 +656,7 @@ struct self_test void testCqeRewrite() { - io_uring ring; + ::io_uring ring; io_uring_params p{}; BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); int sv[2]; @@ -683,7 +683,7 @@ struct self_test // the kernel clears. Rewriting `res` alone cannot reach them. void testCqeFlagsCleared() { - io_uring ring; + ::io_uring ring; io_uring_params p{}; BOOST_TEST_EQ(io_uring_queue_init_params(4, &ring, &p), 0); int sv[2]; diff --git a/test/unit/fault/signal_pipe_faults.cpp b/test/unit/fault/signal_pipe_faults.cpp new file mode 100644 index 000000000..71aef6c0f --- /dev/null +++ b/test/unit/fault/signal_pipe_faults.cpp @@ -0,0 +1,180 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include + +#include +#include +#include +#include + +#if BOOST_COROSIO_POSIX + +namespace boost::corosio::test::fault { + +/* Creating the signal self-pipe, in a process where it does not exist. + + The pipe is a process-wide singleton latched on success, so every + fault below is reachable exactly once per process and only while it + is still unopened. That is what makes this a suite of its own rather + than a test: CTest runs one process per suite, and b2 one executable + per source. The suite name sorts after every other fault suite, so a + single-process run of the whole executable still meets the tests in + an order where the pipe is still closed when they need it to be. + + Within the suite the order is load-bearing too, and run() spells it + out: the creation faults first, then the one test that lets creation + succeed, then the registration faults that need a pipe to register. +*/ +struct signal_pipe_faults +{ + void testPipeCreateFails() + { + { + io_context ioc(one_backend); + signal_set ss(ioc); + int const before = open_fds(); + fault_scope f(sys::pipe, EMFILE); + auto ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::too_many_files_open); + BOOST_TEST_EQ(open_fds(), before); + } + // Three fcntl calls configure each end, read end first: 1-3 + // fail the read end, 4-6 the write end. Either way both ends + // are closed before the error is reported. + for(unsigned nth : {1u, 2u, 3u, 4u, 5u, 6u}) + { + io_context ioc(one_backend); + signal_set ss(ioc); + int const before = open_fds(); + fault_scope f(sys::fcntl, EINVAL, nth); + auto ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST_EQ(open_fds(), before); + } + } + + /* The first test that lets the pipe be created, and it creates it + above FD_SETSIZE on purpose: the select scheduler watches the + read end through an fd_set, so a number it cannot represent is + refused there rather than in the pipe's own setup. Every later + test in the process inherits that high-numbered pipe, which the + other backends have no trouble with. + */ + void testSelectReaderRejectsHighFd() + { +#if BOOST_COROSIO_HAS_SELECT + io_context ioc(select); + signal_set ss(ioc); + fd_wall wall; + if(!wall.ok()) + { + skip_no_high_fd("testSelectReaderRejectsHighFd"); + return; + } + auto ec = ss.add(SIGUSR2); + BOOST_TEST(ec == std::errc::too_many_files_open); +#endif + } + + void testReaderRegisterFails() + { +#if BOOST_COROSIO_HAS_EPOLL + constexpr sys register_call = sys::epoll_ctl; +#elif BOOST_COROSIO_HAS_KQUEUE + constexpr sys register_call = sys::kevent; +#else + return; +#endif +#if BOOST_COROSIO_HAS_EPOLL || BOOST_COROSIO_HAS_KQUEUE + io_context ioc(one_backend); + signal_set ss(ioc); + std::error_code ec; + { + fault_scope f(register_call, ENOMEM); + ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + } + BOOST_TEST(ec == std::errc::not_enough_memory); + // Success-latched, so a failed registration is retried by the + // next add() rather than lost. + BOOST_TEST(!ss.add(SIGUSR2)); + BOOST_TEST(!ss.clear()); +#endif + } + +#if BOOST_COROSIO_HAS_IO_URING + void testUringReaderSubmitFails() + { + io_context ioc(io_uring); + signal_set ss(ioc); + std::error_code ec; + { + // The wakeup eventfd's submit is spent building the ring, + // before this arm, so the reader's is the first it sees. + fault_scope f(sys::io_uring_submit, EBADF); + ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + } + BOOST_TEST(ec == std::errc::bad_file_descriptor); + BOOST_TEST(!ss.add(SIGUSR2)); + BOOST_TEST(!ss.clear()); + } + + /* A ring clamped to one SQE spends it on the wakeup poll, leaving + none for the signal reader's multishot poll. The submit that + follows the failed acquisition succeeds because it has nothing + left to submit, which is not the same as a reader watching the + pipe, so the arm has to report rather than assume. + */ + void testUringReaderSqFull() + { + // The clamp sizes the ring as it is created, which happens + // while the context is built, so it is armed before that. + std::optional f; + f.emplace(sys::uring_sqe_full, 0); + io_context ioc(io_uring); + signal_set ss(ioc); + std::error_code const ec = ss.add(SIGUSR2); + BOOST_TEST(f->fired()); + f.reset(); + BOOST_TEST(ec == std::errc::resource_unavailable_try_again); + // With the SQ flushable again the next add() arms the reader. + BOOST_TEST(!ss.add(SIGUSR2)); + BOOST_TEST(!ss.clear()); + } +#endif + + void run() + { + if(skip_under_valgrind()) + return; + testPipeCreateFails(); + testSelectReaderRejectsHighFd(); + testReaderRegisterFails(); +#if BOOST_COROSIO_HAS_IO_URING + testUringReaderSubmitFails(); + testUringReaderSqFull(); +#endif + } +}; + +TEST_SUITE(signal_pipe_faults, "boost.corosio.fault.signal_pipe"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/signal_sigaction_faults.cpp b/test/unit/fault/signal_sigaction_faults.cpp new file mode 100644 index 000000000..0ef39be18 --- /dev/null +++ b/test/unit/fault/signal_sigaction_faults.cpp @@ -0,0 +1,99 @@ +// +// 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 +// + +#include "fault.hpp" +#include "fault_test_utils.hpp" +#include "context.hpp" +#include "test_suite.hpp" + +#include +#include + +#include +#include +#include + +#if BOOST_COROSIO_POSIX + +namespace boost::corosio::test::fault { + +/* Installing and restoring a signal disposition. + + sigaction() is only reached on the transition at each end of a + signal's global registration count, so each test here has to own + that count for the signal it uses and hand it back before the next + one runs. A suite of its own for the same reason the pipe faults + have one: the disposition is process state, and a suite that shares + a process with tests that register signals cannot say what the + count was when it started. +*/ +struct signal_sigaction_faults +{ + void testAddFails() + { + io_context ioc(one_backend); + signal_set ss(ioc); + { + fault_scope f(sys::sigaction, EINVAL); + auto ec = ss.add(SIGUSR2); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + } + // The half-built registration is destroyed rather than linked, + // so the retry starts from an empty set and installs. + BOOST_TEST(!ss.add(SIGUSR2)); + BOOST_TEST(!ss.clear()); + } + + void testRemoveFails() + { + io_context ioc(one_backend); + signal_set ss(ioc); + BOOST_TEST(!ss.add(SIGUSR2)); + { + fault_scope f(sys::sigaction, EINVAL); + BOOST_TEST(ss.remove(SIGUSR2) == std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } + // A failed restore leaves the registration in place, so the + // count and the disposition stay in step and the retry works. + BOOST_TEST(!ss.remove(SIGUSR2)); + BOOST_TEST(!ss.clear()); + } + + void testClearFails() + { + io_context ioc(one_backend); + signal_set ss(ioc); + BOOST_TEST(!ss.add(SIGUSR2)); + { + fault_scope f(sys::sigaction, EINVAL); + BOOST_TEST(ss.clear() == std::errc::invalid_argument); + BOOST_TEST(f.fired()); + } + // clear() reports the first failure but still empties the set, + // so there is nothing left for a retry to find. + BOOST_TEST(!ss.clear()); + } + + void run() + { + if(skip_under_valgrind()) + return; + testAddFails(); + testRemoveFails(); + testClearFails(); + } +}; + +TEST_SUITE(signal_sigaction_faults, "boost.corosio.fault.signal_sigaction"); + +} // boost::corosio::test::fault + +#endif diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp index 145162002..e467afec7 100644 --- a/test/unit/fault/uring_faults.cpp +++ b/test/unit/fault/uring_faults.cpp @@ -78,55 +78,6 @@ struct uring_faults std::errc::bad_file_descriptor); } - void testSignalReaderSubmitFails() - { - // A successful add opens the process-global self-pipe and - // installs its handlers; doing that here would disarm the - // tests that fault exactly that setup, so it stays in a child. - in_child([]{ - io_context ioc(io_uring); - signal_set ss(ioc); - std::error_code ec; - bool fired = false; - { - // The wakeup eventfd's submit is spent building the - // ring, before this arm, so the reader's is the first - // one it sees. - fault_scope f(sys::io_uring_submit, EBADF); - ec = ss.add(SIGUSR2); - fired = f.fired(); - } - // Not latched: the registration is retried by the next add(). - return fired && ec == std::errc::bad_file_descriptor && - !ss.add(SIGUSR2) && !ss.clear(); - }); - } - - // A ring clamped to one SQE spends it on the wakeup poll when the - // ring is created, leaving none for the signal reader's multishot - // poll. The submit that follows succeeds because it has nothing - // left to submit, which is not the same as a reader watching the - // pipe. - void testSignalReaderSqFull() - { - in_child([]{ - // The clamp sizes the ring as it is created, which happens - // while the context is built, so it is armed before that. - std::optional f; - f.emplace(sys::uring_sqe_full, 0); - io_context ioc(io_uring); - signal_set ss(ioc); - std::error_code const ec = ss.add(SIGUSR2); - bool const fired = f->fired(); - f.reset(); - // Not latched: with the SQ flushable again the next add() - // arms the reader. - return fired && - ec == std::errc::resource_unavailable_try_again && - !ss.add(SIGUSR2) && !ss.clear(); - }); - } - void testWaitFails() { { @@ -693,8 +644,6 @@ struct uring_faults if(skip_under_valgrind()) return; testRingInitFails(); - testSignalReaderSubmitFails(); - testSignalReaderSqFull(); testWaitFails(); testAcceptorDrainSubmitFails(); testAcceptorArmSqFull(); From b1c43ed52d9064b6e2defcaa09a0b703160688df Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 04:31:54 +0200 Subject: [PATCH 20/34] test(epoll): adopted acceptors and the accept the reactor dispatches An acceptor reaches the reactor two ways and only one of them was tested. listen() registers a descriptor the library made; assign() registers one the caller made, and only that path has to hand the descriptor back untouched when the reactor refuses it. The accept tests so far all resolved on the initiator's own speculative accept. A first accept with no peer waiting parks the operation, so the reactor's retry runs a different set of arms: the accept's own error leg and the registration of the accepted descriptor from the posted completion, with its rollback. --- test/unit/fault/epoll_faults.cpp | 119 +++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/test/unit/fault/epoll_faults.cpp b/test/unit/fault/epoll_faults.cpp index 911bbe11d..bc9cd61bf 100644 --- a/test/unit/fault/epoll_faults.cpp +++ b/test/unit/fault/epoll_faults.cpp @@ -11,6 +11,7 @@ #include "fault_test_utils.hpp" #include "context.hpp" #include "test_suite.hpp" +#include "test_utils.hpp" #include #include @@ -26,6 +27,9 @@ #include #include +#include +#include + #if BOOST_COROSIO_HAS_EPOLL namespace boost::corosio::test::fault { @@ -252,6 +256,118 @@ struct epoll_faults BOOST_TEST(done); } + /* Adoption is the acceptor's other way into the reactor. + + `listen()` registers a descriptor the library made; `assign()` + registers one the caller made, and only that path leaves the + caller still owning it when the reactor refuses. The acceptor + has to come back closed with the descriptor untouched. + */ + void testAcceptorAssignRegisterFails() + { + io_context ioc(epoll); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(static_cast(h) >= 0); + make_native_adoptable(h); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::bind(static_cast(h), + reinterpret_cast(&sa), sizeof(sa)), 0); + BOOST_TEST_EQ(::listen(static_cast(h), 1), 0); + + int const before = open_fds(); + tcp_acceptor acc(ioc); + { + fault_scope f(sys::epoll_ctl, ENOMEM); + auto ec = acc.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::not_enough_memory); + } + BOOST_TEST(!acc.is_open()); + BOOST_TEST(native_socket_valid(h)); + BOOST_TEST_EQ(open_fds(), before); + // Not latched: the descriptor is still unregistered. + BOOST_TEST(!acc.assign(h)); + acc.close(); + } + + /* The accept that the reactor dispatches, rather than the one the + initiator speculates on. + + A first accept4 with no peer waiting reports EAGAIN and parks the + op, so the second call is the reactor's retry — a different code + path with its own error and completion arms, and the one an + accepted descriptor is registered from. + */ + void testPostedAcceptFails() + { + io_context ioc(epoll); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + // Opened before any arm so its own registration is not counted. + BOOST_TEST(!client.open(tcp::v4())); + std::error_code aec; + auto accept_body = [&]() -> capy::task<> + { + { + // 1 is the speculative accept, which finds nothing. + fault_scope f(sys::accept4, EMFILE, 2); + auto [ec] = co_await acc.accept(server); + aec = ec; + BOOST_TEST(f.fired()); + } + // A refused accept dequeues nothing, so the acceptor is + // still open and the connection still pending. + auto [ec] = co_await acc.accept(server); + BOOST_TEST(!ec); + }; + auto connect_body = [&]() -> capy::task<> + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(accept_body()); + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + BOOST_TEST(aec == std::errc::too_many_files_open); + BOOST_TEST(server.is_open()); + } + + void testPostedAcceptRegisterFails() + { + io_context ioc(epoll); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + BOOST_TEST(!client.open(tcp::v4())); + std::error_code aec; + int leaked = 0; + auto accept_body = [&]() -> capy::task<> + { + int const before = open_fds(); + fault_scope f(sys::epoll_ctl, ENOMEM); + auto [ec] = co_await acc.accept(server); + aec = ec; + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + // The peer implementation owns the accepted descriptor by + // then, so destroying it is what closes it. + leaked = open_fds() - before; + }; + auto connect_body = [&]() -> capy::task<> + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(accept_body()); + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + BOOST_TEST(aec == std::errc::not_enough_memory); + BOOST_TEST(!server.is_open()); + BOOST_TEST_EQ(leaked, 0); + } + void run() { if(skip_under_valgrind()) @@ -262,6 +378,9 @@ struct epoll_faults testAcceptFails(); testRunLoopFaults(); testInterruptWriteFails(); + testAcceptorAssignRegisterFails(); + testPostedAcceptFails(); + testPostedAcceptRegisterFails(); } }; From f1552f788f4023f25967784e78ab6a835347b182 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 17:35:52 +0200 Subject: [PATCH 21/34] test(kqueue): the acceptor paths this backend registers on its own Three registration and configuration legs had no kqueue caller. An acceptor's open goes through a configure function of its own, an acceptor adopted through assign registers from a third place, and an accept the reactor dispatches registers the descriptor it produced from the completion rather than from the initiator. kevent is this backend's only registration symbol and the run loop waits on it too, so an ordinal over the whole call sequence was stale the moment scheduling shifted. The arm now lands on the registration itself, a slice of the kevent shadow that fires only on a changelist carrying EV_ADD; when both arms match one call the plain kevent arm publishes last and its errno is the one the caller sees. --- test/unit/fault/fault.hpp | 7 +- test/unit/fault/fault_posix.cpp | 31 ++++++-- test/unit/fault/kqueue_faults.cpp | 115 ++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 5 deletions(-) diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp index f0698185d..bdeda8d86 100644 --- a/test/unit/fault/fault.hpp +++ b/test/unit/fault/fault.hpp @@ -26,6 +26,11 @@ namespace boost::corosio::test::fault { `uring_sqe_full` enumerator is not a symbol: arming it clamps the next ring to one SQE and turns `io_uring_submit` into a no-op so `io_uring_get_sqe` returns null on the second acquisition. + `kevent_register` is not a symbol either: it names the subset of + `kevent` calls that add a descriptor to the kqueue, so a test can + reach a registration without counting the waits the run loop makes + on its way there. An arm on `kevent` still counts every call, + registrations included. */ enum class sys { @@ -37,7 +42,7 @@ enum class sys fdatasync, posix_fadvise, unlink, sigaction, getaddrinfo, freeaddrinfo, getnameinfo, gethostname, epoll_create1, epoll_ctl, epoll_wait, eventfd, timerfd_create, - timerfd_settime, select, kqueue, kevent, + timerfd_settime, select, kqueue, kevent, kevent_register, io_uring_queue_init_params, io_uring_queue_exit, io_uring_submit, io_uring_submit_and_wait_timeout, io_uring_submit_and_get_events, io_uring_wait_cqe_timeout, uring_sqe_full, diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp index 1589f6df0..09e3c2905 100644 --- a/test/unit/fault/fault_posix.cpp +++ b/test/unit/fault/fault_posix.cpp @@ -229,10 +229,30 @@ COROSIO_FAULT_HOOK_NX(timerfd_settime, int, -1, (int fd, int f, itimerspec const #if defined(__APPLE__) || defined(__FreeBSD__) COROSIO_FAULT_HOOK(kqueue, int, -1, (), ()) -COROSIO_FAULT_HOOK(kevent, int, -1, - (int kq, struct kevent const* ch, int nch, struct kevent* ev, int nev, - timespec const* ts), - (kq, ch, nch, ev, nev, ts)) +// Hand-written rather than COROSIO_FAULT_HOOK: a registration and a +// wait are the same symbol, so the changelist is the only thing that +// tells them apart. Both arms are consulted on every call so their +// counters stay in step whichever one fires. +extern "C" int kevent( + int kq, struct kevent const* ch, int nch, struct kevent* ev, int nev, + timespec const* ts) +{ + COROSIO_FAULT_REAL(kevent, int(*)(int, struct kevent const*, int, + struct kevent*, int, timespec const*)); + bool adds = false; + for(int i = 0; i < nch; ++i) + { + if(ch[i].flags & EV_ADD) + adds = true; + } + // A call that satisfies both arms fails once: the plain kevent arm + // publishes last, so its errno is the one the caller sees. + bool const fail_add = adds && should_fail(sys::kevent_register); + bool const fail_any = should_fail(sys::kevent); + if(fail_add || fail_any) + return -1; + return real(kq, ch, nch, ev, nev, ts); +} #endif #if defined(__APPLE__) @@ -1041,6 +1061,9 @@ bool hook_is_live(sys which) noexcept // shadows drive, so it lives exactly when they do. if(which == sys::uring_sqe_full) which = sys::io_uring_submit; + // Not a symbol either: it is a slice of the kevent shadow. + if(which == sys::kevent_register) + which = sys::kevent; for(std::size_t i = 0; i < census_count; ++i) { if(census[i].id == which && census_live[i]) diff --git a/test/unit/fault/kqueue_faults.cpp b/test/unit/fault/kqueue_faults.cpp index 04fe1c9e5..40cf2efed 100644 --- a/test/unit/fault/kqueue_faults.cpp +++ b/test/unit/fault/kqueue_faults.cpp @@ -27,6 +27,9 @@ #include #include +#include +#include + #if BOOST_COROSIO_HAS_KQUEUE namespace boost::corosio::test::fault { @@ -126,6 +129,32 @@ struct kqueue_faults BOOST_TEST(!s.is_open()); BOOST_TEST_EQ(open_fds(), before); } + // An acceptor reaches the same options through a configure + // function of its own (kqueue_traits::configure_ip_acceptor), + // which has its own error leg to leave through. + { + int before = open_fds(); + tcp_acceptor acc(ioc); + fault_scope f(sys::fcntl, EINVAL); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(!acc.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } + { + int before = open_fds(); + tcp_acceptor acc(ioc); + fault_scope f(sys::setsockopt, ENOPROTOOPT); + auto ec = acc.open(); + BOOST_TEST(f.fired()); + // v4 asks for no address-family option, so SO_NOSIGPIPE is + // the only setsockopt the open makes. + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::no_protocol_option); + BOOST_TEST(!acc.is_open()); + BOOST_TEST_EQ(open_fds(), before); + } } void testAssignRegisterFails() @@ -399,6 +428,90 @@ struct kqueue_faults }); } + void testAcceptorAssignRegisterFails() + { + io_context ioc(kqueue); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(static_cast(h) >= 0); + make_native_adoptable(h); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::bind(static_cast(h), + reinterpret_cast(&sa), sizeof(sa)), 0); + BOOST_TEST_EQ(::listen(static_cast(h), 1), 0); + + int const before = open_fds(); + tcp_acceptor acc(ioc); + { + // Nothing else on the adopt path reaches kevent, so the + // first call is the registration itself. + fault_scope f(sys::kevent, ENOMEM); + auto ec = acc.assign(h); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 1u); + BOOST_TEST(ec == std::errc::not_enough_memory); + } + // The rollback puts the acceptor back where it was and leaves + // the caller owning the descriptor it passed in + // (reactor_acceptor::init_and_register). + BOOST_TEST(!acc.is_open()); + BOOST_TEST(native_socket_valid(h)); + BOOST_TEST_EQ(open_fds(), before); + BOOST_TEST(!acc.assign(h)); + acc.close(); + } + + /* Registering the descriptor the reactor's own accept produced. + + An accept that runs inline registers from the initiator; one the + reactor dispatches registers from the completion instead, which + is a different arm with a rollback of its own. A first accept + with no peer waiting reports EAGAIN and parks the operation, so + the connection has to arrive afterwards for the retry to be the + reactor's. + */ + void testPostedAcceptRegisterFails() + { + io_context ioc(kqueue); + tcp_acceptor acc(ioc, loopback()); + tcp_socket client(ioc), server(ioc); + // Opened before any arm so its own registration is not counted. + BOOST_TEST(!client.open(tcp::v4())); + std::error_code aec; + int leaked = 0; + unsigned calls = 0; + auto accept_body = [&]() -> capy::task<> + { + int const before = open_fds(); + // Both filters of both descriptors are already registered, + // so the next descriptor the kqueue is asked to add is the + // one the reactor's retry accepts. Counting registrations + // rather than kevent calls keeps the arm off the waits the + // run loop makes while the connection is on its way. + fault_scope f(sys::kevent_register, ENOMEM); + auto [ec] = co_await acc.accept(server); + aec = ec; + calls = f.count(); + // The peer implementation owns the accepted descriptor by + // then, so destroying it is what closes it. + leaked = open_fds() - before; + BOOST_TEST(f.fired()); + }; + auto connect_body = [&]() -> capy::task<> + { + auto [ec] = co_await client.connect(acc.local_endpoint()); + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(accept_body()); + capy::run_async(ioc.get_executor())(connect_body()); + ioc.run(); + BOOST_TEST_EQ(calls, 1u); + BOOST_TEST(aec == std::errc::not_enough_memory); + BOOST_TEST(!server.is_open()); + BOOST_TEST_EQ(leaked, 0); + } + void run() { if(skip_under_valgrind()) @@ -407,7 +520,9 @@ struct kqueue_faults testOpenFails(); testAssignRegisterFails(); testAcceptorRegisterFails(); + testAcceptorAssignRegisterFails(); testAcceptFails(); + testPostedAcceptRegisterFails(); testAcceptConfigureFails(); testRunLoopFaults(); testInterruptTriggerFails(); From 9af2de1f3285d17483776c87a3615c908b50838f Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 04:39:57 +0200 Subject: [PATCH 22/34] test(io_uring): the object surface underneath the ring Every io_uring object type creates and configures its descriptor with plain syscalls, and each of those legs returns an error rather than throwing. None had run: the ring's own faults reach the operations, not the surface underneath them. The tests walk socket, listen, bind and shutdown across the TCP, UDP, AF_UNIX stream and AF_UNIX datagram types and their acceptors, and refuse the SO_ACCEPTCONN query an adopted descriptor is probed with. --- test/unit/fault/uring_faults.cpp | 165 +++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp index e467afec7..e304e8baf 100644 --- a/test/unit/fault/uring_faults.cpp +++ b/test/unit/fault/uring_faults.cpp @@ -15,6 +15,10 @@ #include #include +#include +#include +#include +#include #include #include #include @@ -40,6 +44,7 @@ #include +#include #include #include @@ -78,6 +83,163 @@ struct uring_faults std::errc::bad_file_descriptor); } + /* Every io_uring object type creates and configures its descriptor + with plain syscalls, and each of those returns rather than + throwing. None of the error legs had run: the ring's own faults + reach the operations, not the object surface underneath them. + */ + void testObjectSurfaceErrors() + { + io_context ioc(io_uring); + auto expect_open = [&](auto&& obj, auto&& open_it) + { + int const before = open_fds(); + fault_scope f(sys::socket, EMFILE); + BOOST_TEST(open_it() == std::errc::too_many_files_open); + BOOST_TEST(f.fired()); + BOOST_TEST(!obj.is_open()); + BOOST_TEST_EQ(open_fds(), before); + }; + { + tcp_acceptor acc(ioc); + expect_open(acc, [&]{ return acc.open(); }); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(uring_loopback())); + fault_scope f(sys::listen, EADDRINUSE); + BOOST_TEST(acc.listen() == std::errc::address_in_use); + BOOST_TEST(f.fired()); + } + { + udp_socket u(ioc); + expect_open(u, [&]{ return u.open(udp::v4()); }); + } + { + local_stream_socket ls(ioc); + expect_open(ls, [&]{ return ls.open(); }); + } + { + local_datagram_socket ld(ioc); + expect_open(ld, [&]{ return ld.open(); }); + } + auto path = temp_path("ura"); + ::unlink(path.c_str()); + { + local_stream_acceptor acc(ioc); + expect_open(acc, [&]{ return acc.open(); }); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(corosio::local_endpoint(path))); + fault_scope f(sys::listen, EADDRINUSE); + BOOST_TEST(acc.listen() == std::errc::address_in_use); + BOOST_TEST(f.fired()); + } + ::unlink(path.c_str()); + { + local_datagram_socket ld(ioc); + BOOST_TEST(!ld.open()); + fault_scope f(sys::bind, EACCES); + BOOST_TEST(ld.bind(corosio::local_endpoint(path)) == + std::errc::permission_denied); + BOOST_TEST(f.fired()); + BOOST_TEST(ld.is_open()); + } + ::unlink(path.c_str()); + { + auto [a, b] = test::make_socket_pair(ioc); + std::ignore = b; + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_both) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + { + local_stream_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_both) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + { + local_datagram_socket a(ioc), b(ioc); + BOOST_TEST(!connect_pair(a, b)); + fault_scope f(sys::shutdown, ENOTCONN); + BOOST_TEST(a.shutdown(shutdown_both) == std::errc::not_connected); + BOOST_TEST(f.fired()); + } + } + + /* Multishot accept fails immediately on a socket that is not + listening, so an adopted descriptor is probed before it is + armed. A kernel that refuses the query is treated as reporting a + listener, which is what keeps adoption working on a platform + that has no SO_ACCEPTCONN. + */ + void testAcceptorAssignProbeFails() + { + io_context ioc(io_uring); + auto h = make_native_socket(AF_INET, SOCK_STREAM); + BOOST_TEST(static_cast(h) >= 0); + make_native_adoptable(h); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::bind(static_cast(h), + reinterpret_cast(&sa), sizeof(sa)), 0); + BOOST_TEST_EQ(::listen(static_cast(h), 1), 0); + tcp_acceptor acc(ioc); + { + // 1 is the SO_TYPE query adoption validates with; 2 is the + // SO_ACCEPTCONN probe. + fault_scope f(sys::getsockopt, EBADF, 2); + BOOST_TEST(!acc.assign(h)); + BOOST_TEST(f.fired()); + BOOST_TEST_EQ(f.count(), 2u); + } + BOOST_TEST(acc.is_open()); + acc.close(); + } + + /* Cancellation is best-effort when the submission queue is full. + + A ring clamped to one SQE spends it on the wakeup poll, and the + flush that would free it is a no-op while the arm is alive, so + every cancel below finds the queue still full after its own + retry and gives up. Nothing observable changes — the descriptor + is closed either way — which is why the arm's own report is the + only witness that the give-up was taken. + */ + void testCancelSqFull() + { + std::optional f; + f.emplace(sys::uring_sqe_full, 0); + io_context ioc(io_uring); + { + // close() submits the cancel while the descriptor is still + // open, so the kernel resolves it before the number can be + // recycled. + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + int const before = open_fds(); + s.close(); + BOOST_TEST(!s.is_open()); + BOOST_TEST_EQ(open_fds(), before - 1); + } + { + // cancel() reaches the by-descriptor cancel without going + // through a close. + tcp_socket s(ioc); + BOOST_TEST(!s.open(tcp::v4())); + s.cancel(); + BOOST_TEST(s.is_open()); + } + { + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(uring_loopback())); + BOOST_TEST(!acc.listen()); + } + BOOST_TEST(f->fired()); + f.reset(); + } + void testWaitFails() { { @@ -644,6 +806,9 @@ struct uring_faults if(skip_under_valgrind()) return; testRingInitFails(); + testObjectSurfaceErrors(); + testAcceptorAssignProbeFails(); + testCancelSqFull(); testWaitFails(); testAcceptorDrainSubmitFails(); testAcceptorArmSqFull(); From c45e58cb87b022c5fce7eff4838720d9792f706e Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 04:31:55 +0200 Subject: [PATCH 23/34] test(reactor): an error event for every kind of parked operation The dispatch completes a parked operation with the error instead of running its I/O, and it has a separate arm for each kind: read, write, wait-for-read, wait-for-write, wait-for-error. Only the read arm had ever run. Each test parks its own kind and faults the SO_ERROR probe, so what the operation reports is the armed code rather than whatever the kernel recorded. A writable descriptor never parks, so the write-direction tests park with a refused small write rather than with backpressure, check that premise themselves and bound the runs a failed premise could leave going. They stay on Linux: kqueue reports a socket error through the read filter, and select's except set answers only for urgent data, so those backends carry no write-direction error arm to reach. The probe for writability and the except set is asked in one select and the premise the reactor's round did not carry is reported rather than asserted. --- test/unit/fault/epoll_faults.cpp | 10 + test/unit/fault/fault_test_utils.hpp | 38 ++ test/unit/fault/reactor_faults.hpp | 556 +++++++++++++++++++++++++++ test/unit/fault/select_faults.cpp | 4 + 4 files changed, 608 insertions(+) diff --git a/test/unit/fault/epoll_faults.cpp b/test/unit/fault/epoll_faults.cpp index bc9cd61bf..e1765daae 100644 --- a/test/unit/fault/epoll_faults.cpp +++ b/test/unit/fault/epoll_faults.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -309,6 +310,7 @@ struct epoll_faults // Opened before any arm so its own registration is not counted. BOOST_TEST(!client.open(tcp::v4())); std::error_code aec; + std::stop_source guard; auto accept_body = [&]() -> capy::task<> { { @@ -322,15 +324,23 @@ struct epoll_faults // still open and the connection still pending. auto [ec] = co_await acc.accept(server); BOOST_TEST(!ec); + guard.request_stop(); }; auto connect_body = [&]() -> capy::task<> { auto [ec] = co_await client.connect(acc.local_endpoint()); BOOST_TEST(!ec); }; + // The second accept only resolves if the refused one left the + // pending connection alone; a run loop that never returns + // would otherwise read as a job timeout rather than a failure. + bool expired = false; capy::run_async(ioc.get_executor())(accept_body()); capy::run_async(ioc.get_executor())(connect_body()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); ioc.run(); + BOOST_TEST(!expired); BOOST_TEST(aec == std::errc::too_many_files_open); BOOST_TEST(server.is_open()); } diff --git a/test/unit/fault/fault_test_utils.hpp b/test/unit/fault/fault_test_utils.hpp index 66e2e1f01..bb4f27b01 100644 --- a/test/unit/fault/fault_test_utils.hpp +++ b/test/unit/fault/fault_test_utils.hpp @@ -15,6 +15,13 @@ #include #include +#if !defined(_WIN32) +#include +#include +#include +#include +#include +#endif #if defined(__FreeBSD__) // real_symbol: the descriptor scan below must not spend a live `fcntl` @@ -250,6 +257,37 @@ inline std::string temp_path(char const* tag) #endif +#if !defined(_WIN32) + +/* Bound a run loop an assertion failure could leave running. + + A test that parks an operation and expects something else to + complete it has no way to fail on its own: if the completion never + comes, run() does not return and the job dies on the CI timeout with + nothing to say which test was waiting. Spawn one of these under a + stop source alongside, request the stop where the test finishes, and + check `expired` after the run. + + The stop source is what keeps the guard from becoming the thing the + run loop waits for: a pending delay is outstanding work, so a guard + that is never cancelled costs its full timeout on every test that + passes. The IOCP suites keep a copy of their own. + + @param ioc The context to stop if the timeout is reached. + @param expired Set when the guard fired rather than being cancelled. +*/ +inline capy::task<> stop_guard(io_context& ioc, bool& expired) +{ + auto [ec] = co_await corosio::delay(std::chrono::seconds(2)); + // Cancelled: the test finished and asked the guard to stand down. + if(ec) + co_return; + expired = true; + ioc.stop(); +} + +#endif + // Return true if the process runs under Valgrind. Valgrind always maps // its preload library, so scanning the map is enough and does not need // valgrind.h on the include path. diff --git a/test/unit/fault/reactor_faults.hpp b/test/unit/fault/reactor_faults.hpp index 39cf27b01..61ff10daf 100644 --- a/test/unit/fault/reactor_faults.hpp +++ b/test/unit/fault/reactor_faults.hpp @@ -30,12 +30,14 @@ #include #include #include +#include #include #include #include #include #include +#include #include namespace boost::corosio::test::fault { @@ -435,6 +437,551 @@ struct reactor_common_faults BOOST_TEST(wec == std::errc::io_error); } + /* Fill the send path until it refuses a small write. + + A write-direction operation parks only on a descriptor the + kernel would refuse, and how much a socket takes before it + refuses is the kernel's business. Neither a `send` that stops + short nor a descriptor that has stopped reporting itself + writable is enough: a stream socket still accepts a few bytes + below its low-water mark, and the operation these tests park is + seven of them. The bulk loop saturates both ends — the peer + never reads, so what stops it is the closed window — and the + small sends that follow are the actual precondition, so the + caller can skip rather than fail an unrelated assertion where + filling does not close it. + + @return True once a small write would be refused. + */ + static bool fill_send_buffer(tcp_socket& s) + { + std::vector chunk(64 * 1024, 'x'); + int const fd = static_cast(s.native_handle()); + // A refused send is not the end of the loop: the peer's receive + // buffer keeps draining ours, so the window reopens until that + // is full too. + for(int i = 0; i < 512; ++i) + std::ignore = ::send(fd, chunk.data(), chunk.size(), MSG_DONTWAIT); + for(int i = 0; i < 4096; ++i) + { + if(::send(fd, chunk.data(), 8, MSG_DONTWAIT) < 0) + return true; + } + return false; + } + + /* Empty a peer's receive buffer so the sender's window reopens. + + The counterpart to fill_send_buffer: the sender is blocked on a + closed window rather than on its own buffer, so what reopens it + is the far end reading. + */ + static void drain_receive_buffer(tcp_socket& s) + { + std::vector buf(64 * 1024); + int const fd = static_cast(s.native_handle()); + while(::recv(fd, buf.data(), buf.size(), MSG_DONTWAIT) > 0) + { + } + } + + // Report a kernel this test cannot put into the state it needs. + static void skip_unfillable(char const* what) + { + std::fprintf(stderr, + "fault harness: the send window would not stay closed on " + "this kernel; skipping %s\n", what); + } + + /* Raise an error condition on the far end of `peer`. + + epoll and kqueue report a reset as an error event, so SO_LINGER + 0 plus a close is enough. select reports an exceptional + condition only for out-of-band data, which is why the two + backends need different triggers for the same dispatch arm. + */ + static void raise_error_condition(tcp_socket& peer) + { + // A parked operation that resolved early leaves nothing here to + // raise the condition on. Report that rather than throwing out + // of a coroutine, where it would abort the process and hide + // whichever assertion actually failed. + BOOST_TEST(peer.is_open()); + if(!peer.is_open()) + return; + if constexpr(is_select) + { + char oob = '!'; + BOOST_TEST_EQ( + ::send(peer.native_handle(), &oob, 1, MSG_OOB), 1); + } + else + { + peer.set_option(socket_option::linger(true, 0)); + peer.close(); + } + } + + /* An error arriving on a descriptor with an operation parked on it. + + The dispatch has a separate arm per parked op kind, each of + which completes the op with the error instead of running its + I/O. The SO_ERROR probe is faulted so what the op reports is the + armed code rather than whatever the kernel recorded — the same + trick testErrorEventSoError uses for the plain read arm. + */ + void testErrorEventOnParkedWaitRead() + { + io_context ioc(Backend); + auto [c, peer] = test::make_socket_pair(ioc); + std::stop_source guard; + std::error_code wec; + auto waiter = [&]() -> capy::task<> + { + fault_scope probe(sys::getsockopt, EBADF); + auto [ec] = co_await c.wait(wait_type::read); + wec = ec; + BOOST_TEST(probe.fired()); + // An out-of-band byte keeps select's except set raised, so + // the socket that raised it goes before the next pass. + peer.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + raise_error_condition(peer); + }; + bool expired = false; + capy::run_async(ioc.get_executor())(waiter()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(wec == std::errc::bad_file_descriptor); + BOOST_TEST(c.is_open()); + } + + void testErrorEventOnParkedWaitError() + { + io_context ioc(Backend); + auto [c, peer] = test::make_socket_pair(ioc); + std::stop_source guard; + std::error_code wec; + auto waiter = [&]() -> capy::task<> + { + fault_scope probe(sys::getsockopt, EBADF); + auto [ec] = co_await c.wait(wait_type::error); + wec = ec; + BOOST_TEST(probe.fired()); + peer.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + raise_error_condition(peer); + }; + bool expired = false; + capy::run_async(ioc.get_executor())(waiter()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(wec == std::errc::bad_file_descriptor); + BOOST_TEST(c.is_open()); + } + + /* The two write-direction arms, on Linux only. + + Both need an operation that is still parked when the error + arrives, so the trigger runs in the run-loop turn straight after + the one that parked it, with nothing posted in between; a socket + whose send window can reopen on its own does not stay parked any + longer than that. Where it resolves even in that gap the test + reports a skip rather than asserting on a dispatch that never + ran. + + The BSD family never reaches the arm at all. A reset delivered + to a parked write surfaces there as writability rather than as + an error condition, on kqueue and on select alike, so the op + re-runs its I/O and reports the real error instead of the + faulted probe's. The trigger that does raise an error condition + on the write side is testErrorEventOnWritableWrite's, which runs + everywhere and covers the same two arms; what these two add is + the reset, and only where a reset reaches them. + */ + void testErrorEventOnParkedWrite() + { + io_context ioc(Backend); + auto [c, peer] = make_backpressured_pair(ioc); + if(!fill_send_buffer(c)) + { + skip_unfillable("testErrorEventOnParkedWrite"); + return; + } + char buf[8] = "1234567"; + std::stop_source guard; + std::error_code wec; + bool done = false, parked = false, probe_fired = false; + auto writer = [&]() -> capy::task<> + { + fault_scope probe(sys::getsockopt, EBADF); + auto [ec, n] = co_await c.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + probe_fired = probe.fired(); + done = true; + peer.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + parked = !done; + if(parked) + raise_error_condition(peer); + co_return; + }; + bool expired = false; + capy::run_async(ioc.get_executor())(writer()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + if(!parked) + { + skip_unfillable("testErrorEventOnParkedWrite"); + return; + } + BOOST_TEST(probe_fired); + BOOST_TEST(wec == std::errc::bad_file_descriptor); + BOOST_TEST(c.is_open()); + } + + void testErrorEventOnParkedWaitWrite() + { + io_context ioc(Backend); + auto [c, peer] = make_backpressured_pair(ioc); + if(!fill_send_buffer(c)) + { + skip_unfillable("testErrorEventOnParkedWaitWrite"); + return; + } + std::stop_source guard; + std::error_code wec; + bool done = false, parked = false, probe_fired = false; + auto waiter = [&]() -> capy::task<> + { + fault_scope probe(sys::getsockopt, EBADF); + auto [ec] = co_await c.wait(wait_type::write); + wec = ec; + probe_fired = probe.fired(); + done = true; + peer.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + parked = !done; + if(parked) + raise_error_condition(peer); + co_return; + }; + bool expired = false; + capy::run_async(ioc.get_executor())(waiter()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + if(!parked) + { + skip_unfillable("testErrorEventOnParkedWaitWrite"); + return; + } + BOOST_TEST(probe_fired); + BOOST_TEST(wec == std::errc::bad_file_descriptor); + BOOST_TEST(c.is_open()); + } + + /* Poll `target` until the except set is raised, and -- where the + parked operation is waiting on the send window -- until it is + writable in the same answer. + + The dispatch arms need one round to report writability and the + except set together. Probing for both is the only way the test + can tell that premise from the two halves holding at different + moments, which is what a kernel that reopens the window a beat + late gives it. Bounded well inside the stop_guard's two seconds, + so a kernel that never shows the pair fails the test rather than + the guard. + + @param target The descriptor the parked operation is on. + @param and_writable Whether writability is part of the premise. + + @return True once every bit the caller needs holds at once. + */ + static bool await_condition(tcp_socket& target, bool and_writable) + { + int const fd = static_cast(target.native_handle()); + for(int i = 0; i < 200; ++i) + { + fd_set w, ex; + FD_ZERO(&w); + FD_ZERO(&ex); + FD_SET(fd, &w); + FD_SET(fd, &ex); + timeval tv{0, 1000}; + if(::select(fd + 1, nullptr, and_writable ? &w : nullptr, + &ex, &tv) > 0 && + FD_ISSET(fd, &ex) && + (!and_writable || FD_ISSET(fd, &w))) + { + return true; + } + } + return false; + } + + /* Raise an except condition on `target` from its peer. + + Out-of-band data is the one condition select reports in the + except set, and it stays raised until the byte is consumed -- + which is what lets a write-direction readiness bit arrive in the + same round. The order is load-bearing twice over. A condition + raised before the operation parks is dispatched on its own, and + the operation then leaves through the tail block for an error + with no readiness bit rather than through the write arm; and a + send is not a delivery, so the turn goes back to the run loop + only once the condition is observable. + + @return True once the except set is raised on `target`. + */ + static bool raise_urgent_byte(tcp_socket& peer, tcp_socket& target) + { + char oob = '!'; + BOOST_TEST_EQ(::send(peer.native_handle(), &oob, 1, MSG_OOB), 1); + return await_condition(target, false); + } + + /* Raise the condition and reopen the window a parked write is + waiting on. + + Draining comes before the probe, not after: the window has to be + open for the probe to find it and the except set in one answer, + and one answer is what the dispatch arm needs. + + @return True once writability and the except set hold together. + */ + static bool + raise_writable_error_condition(tcp_socket& peer, tcp_socket& target) + { + char oob = '!'; + BOOST_TEST_EQ(::send(peer.native_handle(), &oob, 1, MSG_OOB), 1); + drain_receive_buffer(peer); + return await_condition(target, true); + } + + // Report a kernel that will not show us the condition the two + // write-direction arms need. + static void skip_unraisable(char const* what) + { + std::fprintf(stderr, + "fault harness: an urgent byte did not raise the except set " + "on this kernel; skipping %s\n", what); + } + + /* Report a premise that held for the probe and not for the reactor. + + Only the round before the reactor's can be probed from a + coroutine, so a kernel that drops one of the two bits in between + leaves the operation resolving normally -- the arm never ran, and + there is nothing here to assert about. + */ + static void skip_unpaired(char const* what) + { + std::fprintf(stderr, + "fault harness: the reactor's round did not carry both " + "writability and the except set; skipping %s\n", what); + } + + /* The write-direction error arms, reached without a socket error. + + Both arms need the same round to report writability and an error + condition on one descriptor, and a reset does not do that on the + BSD family -- it surfaces as plain writability, so the operation + re-runs its I/O and reports the real error instead. An urgent + byte does: it raises select's except set on a descriptor that is + writable in its own right. + + The SO_ERROR probe is left unfaulted here on purpose. An + out-of-band condition leaves no socket error behind, so the probe + reads back zero and the dispatch substitutes EIO + (reactor_descriptor_state::invoke_deferred_io) -- which is also + what the operation reports, on a socket that is in no way + broken. + */ + void testErrorEventOnWritableWrite() + { + if constexpr(is_select) + { + io_context ioc(Backend); + auto [c, peer] = test::make_socket_pair(ioc); + char buf[8] = "1234567"; + std::stop_source guard; + std::error_code wec; + bool open_after = false, spec_fired = false, raised = false; + auto writer = [&]() -> capy::task<> + { + // Refusing the speculative write is what parks the + // operation. Backpressure would do it too, but how much + // a socket takes before it refuses is the kernel's + // business and a window that closed can reopen before + // the operation reaches it. + fault_scope spec(spec_write, EAGAIN); + auto [ec, n] = co_await c.write_some( + capy::const_buffer(buf, 7)); + std::ignore = n; + wec = ec; + spec_fired = spec.fired(); + // Nothing broke: the condition the dispatch reported + // was one byte of urgent data. + open_after = c.is_open(); + // The byte is never consumed, so the except set stays + // raised; the descriptor that raised it goes before the + // run loop is asked for another pass. + c.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + raised = raise_urgent_byte(peer, c); + co_return; + }; + bool expired = false; + capy::run_async(ioc.get_executor())(writer()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(spec_fired); + if(!raised) + { + skip_unraisable("testErrorEventOnWritableWrite"); + return; + } + BOOST_TEST(wec == std::errc::io_error); + BOOST_TEST(open_after); + } + } + + void testErrorEventOnWritableWaitWrite() + { + if constexpr(is_select) + { + io_context ioc(Backend); + auto [c, peer] = make_backpressured_pair(ioc); + if(!fill_send_buffer(c)) + { + skip_unfillable("testErrorEventOnWritableWaitWrite"); + return; + } + std::stop_source guard; + std::error_code wec; + bool done = false, parked = false, open_after = false; + bool raised = false; + auto waiter = [&]() -> capy::task<> + { + auto [ec] = co_await c.wait(wait_type::write); + wec = ec; + done = true; + open_after = c.is_open(); + c.close(); + guard.request_stop(); + }; + auto trigger = [&]() -> capy::task<> + { + parked = !done; + if(parked) + raised = raise_writable_error_condition(peer, c); + co_return; + }; + bool expired = false; + capy::run_async(ioc.get_executor())(waiter()); + capy::run_async(ioc.get_executor())(trigger()); + capy::run_async(ioc.get_executor(), guard.get_token())( + stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + if(!parked) + { + skip_unfillable("testErrorEventOnWritableWaitWrite"); + return; + } + if(!raised) + { + skip_unraisable("testErrorEventOnWritableWaitWrite"); + return; + } + if(!wec) + { + skip_unpaired("testErrorEventOnWritableWaitWrite"); + return; + } + BOOST_TEST(wec == std::errc::io_error); + BOOST_TEST(open_after); + } + } + + /* The address family a socket was created with is read back from + the kernel, not remembered, and a v4 destination on a v6 socket + is the one case where the answer changes the sockaddr handed to + connect(). A failed probe reports AF_UNSPEC, which yields the + v4 shape and an address the v6 socket cannot use. + */ + void testSocketFamilyProbeFails() + { + io_context ioc(Backend); + tcp_acceptor acc(ioc, loopback()); + tcp_socket s(ioc); + if(s.open(tcp::v6())) + { + std::fprintf(stderr, + "fault harness: no IPv6 socket on this host; skipping " + "testSocketFamilyProbeFails\n"); + return; + } + std::error_code cec; + unsigned probes = 0; + auto body = [&]() -> capy::task<> + { + fault_scope f(sys::getsockname, EBADF); + auto [ec] = co_await s.connect(acc.local_endpoint()); + cec = ec; + probes = f.count(); + BOOST_TEST(f.fired()); + }; + capy::run_async(ioc.get_executor())(body()); + ioc.run(); + BOOST_TEST_EQ(probes, 1u); + // Which code a kernel picks for an address in the wrong family + // is its own choice: Linux rejects the sockaddr as too short + // for AF_INET6, the BSDs reject the family itself. +#if defined(__linux__) + BOOST_TEST(cec == std::errc::invalid_argument); +#else + BOOST_TEST(cec == std::errc::address_family_not_supported); +#endif + BOOST_TEST(s.is_open()); + } + void testErrorEventSoError() { io_context ioc(Backend); @@ -693,6 +1240,15 @@ struct reactor_common_faults testDeferredWriteFails(); testShutdownAndWaitFails(); testErrorEventSoError(); + testErrorEventOnParkedWaitRead(); + testErrorEventOnParkedWaitError(); +#if defined(__linux__) + testErrorEventOnParkedWrite(); + testErrorEventOnParkedWaitWrite(); +#endif + testErrorEventOnWritableWrite(); + testErrorEventOnWritableWaitWrite(); + testSocketFamilyProbeFails(); testDatagramFails(); } }; diff --git a/test/unit/fault/select_faults.cpp b/test/unit/fault/select_faults.cpp index 20076a429..08418ca6b 100644 --- a/test/unit/fault/select_faults.cpp +++ b/test/unit/fault/select_faults.cpp @@ -312,6 +312,9 @@ struct select_faults skip_no_high_fd("testOpenAboveFdSetsize"); return; } + // The descriptor exists by the time its number is rejected, so + // the failure path owns closing it. + int const before = open_fds(); tcp_socket s(ioc); auto ec = s.open(tcp::v4()); BOOST_TEST(ec == std::errc::too_many_files_open); @@ -319,6 +322,7 @@ struct select_faults tcp_acceptor acc(ioc); BOOST_TEST(acc.open() == std::errc::too_many_files_open); BOOST_TEST(!acc.is_open()); + BOOST_TEST_EQ(open_fds(), before); } void testAcceptAboveFdSetsize() From 09047d754fe9e7b4d045a3bea595c1ef609cdfd7 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 20:30:23 +0200 Subject: [PATCH 24/34] test(iocp): scheduler fallbacks, the wait reactor's answers, the pointers never loaded, POLLPRI The deferred queue had no test at all: every post in the suite succeeded, so neither the queue nor the drain that empties it was ever reached. Three arms on the same symbol walk a post from the continuation overload through the allocating handle overload and into the drain's own re-post. load_extension_functions asks Winsock for AcceptEx and ConnectEx through a socket of its own; failing it leaves both pointers null and every accept and connect after it refused. The wait reactor asks WSAPoll for POLLPRI on an error wait. Rather than assume what the provider makes of that, a test polls with POLLPRI directly and then measures what an error wait costs the context: on the Microsoft provider the poll fails, the reactor's loop ends and a write wait registered afterwards is refused as canceled. The SO_ERROR probe is reached through a write wait instead, with the peer reset before the wait is registered. --- test/unit/fault/iocp_faults.cpp | 596 +++++++++++++++++++++++++++++++- 1 file changed, 593 insertions(+), 3 deletions(-) diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index 8adb9355a..94a9a61ae 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include #include @@ -93,6 +94,37 @@ capy::task<> stop_guard(io_context& ioc, bool& expired) ioc.stop(); } +// Wait until the provider reports the error condition on `s`, and say +// whether it ever did. The wait reactor keys off the same bits, so a +// round that never carried them is a premise the caller did not have +// rather than a failure of the code under test. +bool wait_for_poll_error(tcp_socket& s) +{ + constexpr SHORT err_bits = POLLERR | POLLHUP | POLLNVAL; + WSAPOLLFD pfd{}; + pfd.fd = static_cast(s.native_handle()); + pfd.events = POLLWRNORM; + for(int i = 0; i < 200; ++i) + { + pfd.revents = 0; + if(::WSAPoll(&pfd, 1, 10) > 0 && (pfd.revents & err_bits) != 0) + return true; + } + return false; +} + +// Run into the reset, so the socket has a chance to record it where +// SO_ERROR will report it. A connection the provider has flagged +// through the poll does not necessarily have an error waiting there +// for anyone who has not touched it since. +void touch_after_reset(tcp_socket& s) +{ + auto const fd = static_cast(s.native_handle()); + char byte = '!'; + for(int i = 0; i < 2; ++i) + std::ignore = ::send(fd, &byte, 1, 0); +} + } // namespace /* Faults on the IOCP backend itself: the scheduler, its completion @@ -573,7 +605,13 @@ struct iocp_faults } { // The convenience constructor reports the same codes by - // throwing. + // throwing, once per leg it walks. + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + expect_system_error([&]{ tcp_acceptor acc(ioc, loopback()); }, + std::errc::address_family_not_supported); + BOOST_TEST(f.fired()); + } + { fault_scope f(sys::listen, WSAEOPNOTSUPP); expect_system_error([&]{ tcp_acceptor acc(ioc, loopback()); }, std::errc::operation_not_supported); @@ -1007,8 +1045,12 @@ struct iocp_faults BOOST_TEST(second.fired()); s.close(); } - // And the reactor those wakes were aimed at still works: a - // real wait registers, parks, and is ended by a cancel. + // And a context whose wakes all failed still answers a wait + // rather than leaving it outstanding. What ends this one it + // does not say: an error wait resolves as cancelled whether + // the cancel below reached the reactor or the reactor drained + // it on the way out, and nothing here is armed to tell those + // apart -- testErrorWaitOnThisProvider is what asks which. io_context ioc(iocp); auto pair = make_socket_pair(ioc); auto& s1 = pair.first; @@ -1197,6 +1239,32 @@ struct iocp_faults BOOST_TEST(acc.is_open()); BOOST_TEST(!acc.listen()); } + // The convenience constructor walks open, bind and listen and + // throws at whichever fails. Its bind does not unlink, so the + // open leg -- which never reaches it -- and the listen leg -- + // which leaves the path bound -- each get a path of their own. + { + temp_socket_dir odir; + fault_scope f(sys::WSASocketW, WSAEAFNOSUPPORT); + expect_system_error( + [&]{ + local_stream_acceptor a( + ioc, corosio::local_endpoint(odir.path())); + }, + std::errc::address_family_not_supported); + BOOST_TEST(f.fired()); + } + { + temp_socket_dir ldir; + fault_scope f(sys::listen, WSAEOPNOTSUPP); + expect_system_error( + [&]{ + local_stream_acceptor a( + ioc, corosio::local_endpoint(ldir.path())); + }, + std::errc::operation_not_supported); + BOOST_TEST(f.fired()); + } } void testLocalConnectAcceptFails() @@ -1297,6 +1365,520 @@ struct iocp_faults BOOST_TEST(compec == std::errc::connection_aborted); } + /* The deferred queue, and a re-post that puts an op back on it. + + A post that fails leaves the work on completed_ops_ with + dispatch_required_ raised, and do_one consults that flag only at + the top of its loop. The failure inside the drain therefore + raises it again just after the check, leaving the loop blocked + in GetQueuedCompletionStatus with work waiting: a timer is what + brings it round, and the wake is posted by the timer thread, + whose calls no arm here counts. + */ + void testPostDeferredFallbackRuns() + { + io_context ioc(iocp); + capy::continuation cont{}; + bool ran = false; + bool fired = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + // 1 is the continuation post, 2 the allocating handle + // post it falls back to -- the one that reaches the + // deferred queue -- and 3 the drain's own re-post. + fault_scope f1(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES, 1); + fault_scope f2(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES, 2); + fault_scope f3(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES, 3); + co_await post_awaitable{&cont}; + fired = f1.fired() && f2.fired() && f3.fired(); + } + ran = true; + ioc.stop(); + }; + auto pump = [&]() -> capy::task<> + { + while(!ran) + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(5)); + } + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(pump()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(fired); + BOOST_TEST(ran); + } + + /* An inline completion whose own post fails. + + A synchronous failure is published through on_completion, which + posts a key_result_stored packet to carry it. Landing on the + deferred queue instead is the only fallback that path has, and + the run loop's next turn is what dispatches it -- the flag is + raised while the loop is still inside the packet that resumed + this coroutine, so it is seen on the way back round. + */ + void testInlineCompletionPostFails() + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& a = pair.first; + auto& b = pair.second; + char buf[8] = {}; + std::error_code rec; + bool fired = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + fault_scope r(sys::WSARecv, WSAENOTSOCK); + fault_scope p(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES); + auto [ec, n] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + fired = r.fired() && p.fired(); + } + a.cancel(); + b.cancel(); + 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(fired); + BOOST_TEST(rec == std::errc::not_a_socket); + } + + /* The error probe the wait reactor runs on a raised poll event. + + Only the write and error waits reach the reactor at all: a wait + for readability is a zero-byte WSARecv + (win_tcp_socket_internal::wait). The peer is reset before + the wait is registered, so the reactor's first poll already has + the condition in hand and nothing here depends on a round + landing between two coroutines. + + A write wait rather than an error wait, and the second half + reports no error at all rather than WSAECONNABORTED, because + `events_for_wait(wait_type::error)` asks WSAPoll for POLLPRI -- + see the report for what this round found that costs. + */ + void testWaitReactorErrorProbe() + { + { + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code wec; + bool premise = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + // make_socket_pair leaves both ends on a zero linger, + // so this is a reset rather than an orderly shutdown + // and it leaves SO_ERROR set on the survivor. + s2.close(); + premise = wait_for_poll_error(s1); + touch_after_reset(s1); + auto [ec] = co_await s1.wait(wait_type::write); + wec = ec; + 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(premise); + // Whether the reset the poll reported is also waiting in + // SO_ERROR is the provider's to decide; what the wait owes + // its caller is the probe's answer either way. Named in + // the log so a run that gives neither is legible. + if(wec != std::errc::connection_reset) + { + std::fprintf(stderr, + "fault harness: the reset the poll reported left " + "SO_ERROR reading %d (%s)\n", + wec.value(), wec.message().c_str()); + BOOST_TEST(!wec); + } + BOOST_TEST(s1.is_open()); + s1.close(); + } + + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::optional probe; + std::error_code wec; + bool probed = false; + bool premise = false; + bool expired = false; + auto body = [&]() -> capy::task<> + { + s2.close(); + premise = wait_for_poll_error(s1); + // The probe runs on the reactor's polling thread, so the + // arm has to be process-wide; nothing else calls + // getsockopt while it is up. + probe.emplace(sys::getsockopt, WSAENOTSOCK, 1u, any_thread); + auto [ec] = co_await s1.wait(wait_type::write); + wec = ec; + probed = probe->fired(); + probe.reset(); + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(body()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + // A wait that never resolved leaves the one process-wide arm + // claimed, and the next test to want one would abort. + probe.reset(); + BOOST_TEST(!expired); + BOOST_TEST(premise); + BOOST_TEST(probed); + // The probe answered nothing, and the substitution it falls + // through to is for error waits only, so this round has + // nothing to report. + BOOST_TEST(!wec); + BOOST_TEST(s1.is_open()); + s1.close(); + } + + /* What this provider makes of an error wait. + + events_for_wait asks WSAPoll for POLLPRI on wait_type::error + (win_wait_reactor.hpp:133-141), and a poll the provider refuses + ends the reactor's loop for the whole context: it sets dead_ and + drains everything it holds as aborted (:471-472, :534, :541), + after which register_wait refuses the next comer (:333-340). + Which of those two worlds this runner is in is the provider's + answer and not the library's, so it is recorded rather than + asserted -- the branch a run took is legible in the coverage of + this test. What is asserted either way is that neither answer + leaves an operation outstanding. + */ + void testErrorWaitOnThisProvider() + { + { + // The provider on its own, with none of the library in it. + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + WSAPOLLFD pfd{}; + pfd.fd = static_cast(pair.first.native_handle()); + pfd.events = POLLPRI; + ::WSASetLastError(0); + int const n = ::WSAPoll(&pfd, 1, 0); + int const err = ::WSAGetLastError(); + if(n == SOCKET_ERROR) + { + std::fprintf(stderr, + "fault harness: WSAPoll refuses POLLPRI with %d\n", + err); + BOOST_TEST(err != 0); + } + else + { + std::fprintf(stderr, + "fault harness: WSAPoll accepts POLLPRI, %d ready, " + "revents %d\n", n, static_cast(pfd.revents)); + // Accepted or ignored, the bit must not cost a live + // socket its validity. + BOOST_TEST((pfd.revents & POLLNVAL) == 0); + } + pair.first.close(); + pair.second.close(); + } + + // And what registering one costs the context: a write wait on + // a healthy socket is answered by a live reactor and refused + // by a dead one. + io_context ioc(iocp); + auto pair = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code eec, wec; + bool done_err = false; + bool done_w = false; + bool expired = false; + auto error_wait = [&]() -> capy::task<> + { + auto [ec] = co_await s1.wait(wait_type::error); + eec = ec; + done_err = true; + if(done_w) + ioc.stop(); + }; + auto write_wait = [&]() -> capy::task<> + { + auto [ec] = co_await s2.wait(wait_type::write); + wec = ec; + done_w = true; + // Nothing raises the error condition on s1, so a reactor + // still polling has to be told to let that wait go. + s1.cancel(); + if(done_err) + ioc.stop(); + }; + capy::run_async(ioc.get_executor())(error_wait()); + capy::run_async(ioc.get_executor())(write_wait()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(done_err); + BOOST_TEST(done_w); + BOOST_TEST(eec == capy::error::canceled); + if(wec == capy::error::canceled) + { + // Refused rather than answered: the reactor was already + // gone when this wait asked to register. + std::fprintf(stderr, + "fault harness: a write wait was refused after an error " + "wait had been registered\n"); + BOOST_TEST(s2.is_open()); + } + else + { + BOOST_TEST(!wec); + } + s1.close(); + s2.close(); + } + + /* 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 + 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 + reaches only the tcp connect. + */ + void testExtensionPointersMissing() + { + // The bootstrap's socket is the first WSASocketW of the + // construction; the wakeup pair uses ::socket. The arm is + // spent by the time the rest of the test runs. + fault_scope arm(sys::WSASocketW, WSAEMFILE); + io_context ioc(iocp); + BOOST_TEST(arm.fired()); + + temp_socket_dir dir; + auto const ep = corosio::local_endpoint(dir.path()); + tcp_acceptor acc(ioc, loopback()); + local_stream_acceptor lacc(ioc); + BOOST_TEST(!lacc.open()); + BOOST_TEST(!lacc.bind(ep, bind_option::unlink_existing)); + BOOST_TEST(!lacc.listen()); + std::error_code tec, lec, cec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + { + // The accepted socket is created before the pointer is + // consulted, so the refusal owns closing it. + tcp_socket server(ioc); + auto [ec] = co_await acc.accept(server); + tec = ec; + BOOST_TEST(!server.is_open()); + } + { + local_stream_socket peer(ioc); + auto [ec] = co_await lacc.accept(peer); + lec = ec; + BOOST_TEST(!peer.is_open()); + } + { + local_stream_socket c(ioc); + BOOST_TEST(!c.open()); + auto [ec] = co_await c.connect(ep); + cec = ec; + BOOST_TEST(c.is_open()); + } + 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(tec == std::errc::operation_not_supported); + BOOST_TEST(lec == std::errc::operation_not_supported); + BOOST_TEST(cec == std::errc::operation_not_supported); + } + + // Adoption on the acceptors, which learn the family and type from + // SO_PROTOCOL_INFOW the way the sockets do and associate before + // letting go of what they held. + void testAcceptorAssignFails() + { + io_context ioc(iocp); + { + auto h = make_native_socket(AF_INET, SOCK_STREAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + tcp_acceptor acc(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + BOOST_TEST(acc.assign(h) == std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(!acc.is_open()); + } + { + tcp_acceptor acc(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST(acc.assign(h) == + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!acc.is_open()); + } + }); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + { + auto h = make_native_socket(AF_UNIX, SOCK_STREAM); + make_native_adoptable(h); + expect_no_handle_leak([&]{ + { + local_stream_acceptor acc(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK); + BOOST_TEST(acc.assign(h) == std::errc::not_a_socket); + BOOST_TEST(f.fired()); + BOOST_TEST(!acc.is_open()); + } + { + local_stream_acceptor acc(ioc); + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST(acc.assign(h) == + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!acc.is_open()); + } + }); + BOOST_TEST(native_socket_valid(h)); + close_native_socket(h); + } + } + + // The acceptor's own option surface, which the socket suite covers + // for sockets only. + void testAcceptorOptionsFail() + { + io_context ioc(iocp); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + { + fault_scope f(sys::setsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ acc.set_option(socket_option::reuse_address(true)); }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::getsockopt, WSAENOTSOCK); + expect_system_error( + [&]{ + std::ignore = + acc.get_option(); + }, + std::errc::not_a_socket); + BOOST_TEST(f.fired()); + } + BOOST_TEST(acc.is_open()); + } + + /* The AF_UNIX legs that refuse before the kernel takes the work. + + Each of these is the initiating call reporting a Winsock error + inline, which the service publishes through on_completion rather + than through a completion packet, plus the synchronous shutdown + the socket answers for itself. + */ + void testLocalIoFails() + { + io_context ioc(iocp); + temp_socket_dir dir; + auto const ep = corosio::local_endpoint(dir.path()); + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(ep, bind_option::unlink_existing)); + BOOST_TEST(!acc.listen()); + char buf[8] = {}; + char out[4] = "abc"; + std::error_code rec, wec, wtec, sdec; + bool expired = false; + auto body = [&]() -> capy::task<> + { + local_stream_socket c(ioc), peer(ioc); + BOOST_TEST(!c.open()); + { + auto [ec] = co_await c.connect(ep); + BOOST_TEST(!ec); + } + { + auto [ec] = co_await acc.accept(peer); + BOOST_TEST(!ec); + } + { + fault_scope f(sys::WSARecv, WSAECONNRESET); + auto [ec, n] = co_await c.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::WSASend, WSAENOBUFS); + auto [ec, n] = co_await c.write_some( + capy::const_buffer(out, 3)); + std::ignore = n; + wec = ec; + BOOST_TEST(f.fired()); + } + { + // A wait for readability is a zero-byte WSARecv here + // too. + fault_scope f(sys::WSARecv, WSAENOTSOCK); + auto [ec] = co_await c.wait(wait_type::read); + wtec = ec; + BOOST_TEST(f.fired()); + } + { + fault_scope f(sys::shutdown, WSAENOTCONN); + sdec = c.shutdown(local_stream_socket::shutdown_both); + BOOST_TEST(f.fired()); + } + c.cancel(); + peer.cancel(); + c.close(); + peer.close(); + 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(rec == std::errc::connection_reset); + BOOST_TEST(wec == win_err(WSAENOBUFS)); + BOOST_TEST(wtec == std::errc::not_a_socket); + BOOST_TEST(sdec == win_err(WSAENOTCONN)); + } + void run() { testSchedulerConstructFails(); @@ -1304,6 +1886,8 @@ struct iocp_faults testTimerThreadWaitFails(); testStopPostFails(); testPostFallbackRuns(); + testPostDeferredFallbackRuns(); + testInlineCompletionPostFails(); testRunLoopDequeueFails(); testTcpOpenFails(); testTcpAssignFails(); @@ -1314,15 +1898,21 @@ struct iocp_faults testTcpConnectFails(); testTcpReadWriteFails(); testAcceptorOpenFails(); + testAcceptorOptionsFail(); + testAcceptorAssignFails(); testAcceptFails(); + testExtensionPointersMissing(); testUdpSetupFails(); testUdpIoFails(); testWaitReactorSetupFails(); testWaitReactorStartsOnFirstWait(); testWakeSendFails(); testWaitReactorPollFails(); + testWaitReactorErrorProbe(); + testErrorWaitOnThisProvider(); testLocalSetupFails(); testLocalConnectAcceptFails(); + testLocalIoFails(); } }; From 0df2a9962ba6ad40d39207cb14cd0189d70b90af Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 20:30:25 +0200 Subject: [PATCH 25/34] test(win): the file, resolver and pair paths that answer without failing Three of these are not failures at all: a data-only flush that declines still syncs, an accept that reports WSAEWOULDBLOCK after a readiness report still forms the pair, and a reverse lookup whose conversion gives up still succeeds with an empty name. Each is a branch nothing reached because nothing had a reason to take it. The rest are ordinary rollbacks: adoption refused on a file handle, SetEndOfFile refused on a truncating open, and the second of connect_pair's two adoptions refused, which is the one that has a live socket on each side to close. --- test/unit/fault/win_faults.cpp | 181 +++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/test/unit/fault/win_faults.cpp b/test/unit/fault/win_faults.cpp index b339ef7d5..07a9ed042 100644 --- a/test/unit/fault/win_faults.cpp +++ b/test/unit/fault/win_faults.cpp @@ -12,6 +12,7 @@ #include "context.hpp" #include "test_suite.hpp" +#include #include #include #include @@ -46,6 +47,17 @@ void remove_file(std::string const& path) std::ignore = std::filesystem::remove(std::filesystem::path(path), ec); } +// A file handle the library did not open, in the mode adoption needs. +// The path is one temp_path built, so widening it a character at a +// time is enough. +HANDLE make_native_file(std::string const& path) +{ + std::wstring wide(path.begin(), path.end()); + return ::CreateFileW(wide.c_str(), GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, + FILE_FLAG_OVERLAPPED, nullptr); +} + // Run connect_pair with a deadline. A rendezvous that cannot finish // would otherwise stall until the CI job runs out of time, which reads // as an infrastructure failure rather than as this test; leaving the @@ -70,6 +82,18 @@ connect_pair_bounded(local_stream_socket& a, local_stream_socket& b) return ec; } +// Bound a run loop an assertion failure could leave running. Only the +// deferred-post test needs one: every other test here either drives a +// synchronous call or awaits an operation the fault completes on the +// spot, whereas a post that never reaches the deferred drain leaves +// run() with work outstanding and nothing to deliver it. +capy::task<> stop_guard(io_context& ioc, bool& expired) +{ + std::ignore = co_await corosio::delay(std::chrono::seconds(2)); + expired = true; + ioc.stop(); +} + } // namespace /* Faults on the Windows entry points that are not the IOCP backend's @@ -193,6 +217,13 @@ struct win_common_faults BOOST_TEST(sf.resize(16) == win_err(ERROR_DISK_FULL)); BOOST_TEST(f.fired()); } + { + // With the data-only flush declined the full flush is the + // only path left, and it succeeds: declining is not a + // failure to report. + fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); + BOOST_TEST(!sf.sync_data()); + } { // sync_data tries the data-only NT flush first and only // falls back to FlushFileBuffers when that fails, so the @@ -310,6 +341,16 @@ struct win_common_faults file_base::create) == win_err(ERROR_INVALID_PARAMETER)); BOOST_TEST(f.fired()); }); + // create|truncate lowers to OPEN_ALWAYS plus an explicit + // SetEndOfFile; every other mode leaves it to the disposition. + expect_no_handle_leak([&]{ + fault_scope f(sys::SetEndOfFile, ERROR_DISK_FULL); + BOOST_TEST(rf.open(path, file_base::read_write | + file_base::create | file_base::truncate) == + win_err(ERROR_DISK_FULL)); + BOOST_TEST(f.fired()); + BOOST_TEST(!rf.is_open()); + }); BOOST_TEST(!rf.open(path, file_base::read_write | file_base::create)); { fault_scope f(sys::GetFileSizeEx, ERROR_INVALID_HANDLE); @@ -328,6 +369,10 @@ struct win_common_faults BOOST_TEST(rf.resize(16) == win_err(ERROR_DISK_FULL)); BOOST_TEST(f.fired()); } + { + fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); + BOOST_TEST(!rf.sync_data()); + } { fault_scope nt(sys::NtFlushBuffersFileEx, ERROR_INVALID_FUNCTION); fault_scope f(sys::FlushFileBuffers, ERROR_WRITE_FAULT); @@ -500,6 +545,29 @@ struct win_common_faults BOOST_TEST(ec == std::errc::not_a_socket); BOOST_TEST(!a.is_open() && !b.is_open()); }); + // A readiness report the accept cannot satisfy is not an + // error: the loop goes back to polling and the pair forms. + // The accept is on this thread, so a thread-local arm reaches + // it without touching the worker's own calls. + { + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::accept, WSAEWOULDBLOCK); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(!ec); + BOOST_TEST(a.is_open() && b.is_open()); + } + // Adoption of the second descriptor fails after the first + // took: one end is the library's to close and the other is + // still a bare socket, and both have to go. + expect_no_handle_leak([&]{ + local_stream_socket a(ioc), b(ioc); + fault_scope f(sys::getsockopt, WSAENOTSOCK, 2u); + auto ec = connect_pair(a, b); + BOOST_TEST(f.fired()); + BOOST_TEST(ec == std::errc::not_a_socket); + BOOST_TEST(!a.is_open() && !b.is_open()); + }); // Unfaulted, a pair still forms. local_stream_socket a(ioc), b(ioc); BOOST_TEST(!connect_pair_bounded(a, b)); @@ -620,6 +688,116 @@ struct win_common_faults BOOST_TEST(cancel_fired); } + /* Adoption of a handle the library did not open. + + The only thing it does is associate the handle with the + completion port, so a refusal there is its only failure. It + closes what the object held before it tries, which is why the + caller's handle is still theirs afterwards. + */ + void testFileAssignFails() + { + io_context ioc(iocp); + auto path = temp_path("winassign"); + { + HANDLE h = make_native_file(path); + BOOST_TEST(h != INVALID_HANDLE_VALUE); + stream_file sf(ioc); + { + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST( + sf.assign(reinterpret_cast(h)) == + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!sf.is_open()); + } + BOOST_TEST(::CloseHandle(h) != FALSE); + } + { + HANDLE h = make_native_file(path); + BOOST_TEST(h != INVALID_HANDLE_VALUE); + random_access_file rf(ioc); + { + fault_scope f(sys::CreateIoCompletionPort, + ERROR_INVALID_PARAMETER); + BOOST_TEST( + rf.assign(reinterpret_cast(h)) == + win_err(ERROR_INVALID_PARAMETER)); + BOOST_TEST(f.fired()); + BOOST_TEST(!rf.is_open()); + } + BOOST_TEST(::CloseHandle(h) != FALSE); + } + remove_file(path); + } + + /* A completion the thread pool never carried. + + A GetAddrInfoExW that fails without going asynchronous is + finished on the calling thread, and win_resolver::resolve posts + the operation rather than resuming it inline. That post is the + scheduler's scheduler_op overload, whose only fallback when the + completion port refuses the packet is the deferred queue, which + the run loop drains on its next turn. + */ + void testResolverPostFallbackRuns() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code fec; + bool fired = false; + auto t = [&]() -> capy::task<> + { + fault_scope g(sys::GetAddrInfoExW, WSAEAFNOSUPPORT); + fault_scope p(sys::PostQueuedCompletionStatus, + ERROR_NO_SYSTEM_RESOURCES); + auto [ec, results] = co_await r.resolve("localhost", "80"); + std::ignore = results; + fec = ec; + fired = g.fired() && p.fired(); + ioc.stop(); + }; + bool expired = false; + capy::run_async(ioc.get_executor())(t()); + capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); + ioc.run(); + BOOST_TEST(!expired); + BOOST_TEST(fired); + BOOST_TEST(fec == std::errc::address_family_not_supported); + } + + /* The conversion on the way back out. + + A reverse lookup's answer arrives wide and is converted on the + pool thread, so the arm has to be process-wide. A size query of + zero leaves the name empty and reports nothing + (resolver_detail::from_wide): the lookup itself succeeded. + */ + void testResolverReverseWideConversionFails() + { + io_context ioc(iocp); + resolver r(ioc); + std::error_code rec; + std::string host = "unset"; + bool fired = false; + auto t = [&]() -> capy::task<> + { + fault_scope f(sys::WideCharToMultiByte, + ERROR_NO_UNICODE_TRANSLATION, 1, any_thread); + auto [ec, result] = co_await r.resolve( + endpoint(ipv4_address::loopback(), 80)); + rec = ec; + host = result.host_name(); + fired = f.fired(); + }; + capy::run_async(ioc.get_executor())(t()); + ioc.run(); + BOOST_TEST(fired); + BOOST_TEST(!rec); + BOOST_TEST(host.empty()); + } + void run() { testHostNameFails(); @@ -627,10 +805,13 @@ struct win_common_faults testStreamFileSyncOps(); testStreamFileIoFails(); testRandomAccessFileFails(); + testFileAssignFails(); testConnectPairFails(); testAvailableThrows(); testResolverFails(); + testResolverPostFallbackRuns(); testResolverWideConversionFails(); + testResolverReverseWideConversionFails(); testResolverCancelIgnored(); } }; From 958383edddb1fde37c88ad8b3be9b8840f370ee2 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 27 Aug 2026 23:47:29 +0200 Subject: [PATCH 26/34] test(teardown): drain every queued completion and live implementation at shutdown The parked-operation teardown tests reach the services that cancel an outstanding operation, but never the handler arm that runs when a completion is already sitting in the scheduler queue at destruction: the run loop had always delivered everything it reaped. Making two operations ready and dispatching one leaves the other for shutdown, which is the proactor's !owner path and the reactor's queued-op destroy, for reads, waits, writes, connects, datagrams, accepts and acceptor waits. A service's shutdown walk over implementations still alive had no caller either, since an io object cannot outlive its context. Keeping the object inside an abandoned coroutine frame reaches it. --- test/unit/local_datagram_socket.cpp | 44 +++++++++ test/unit/local_stream_socket.cpp | 145 ++++++++++++++++++++++++++++ test/unit/random_access_file.cpp | 41 ++++++++ test/unit/resolver.cpp | 36 +++++++ test/unit/stream_file.cpp | 41 ++++++++ test/unit/tcp_acceptor.cpp | 100 +++++++++++++++++++ test/unit/tcp_socket.cpp | 100 +++++++++++++++++++ test/unit/udp_socket.cpp | 53 ++++++++++ 8 files changed, 560 insertions(+) diff --git a/test/unit/local_datagram_socket.cpp b/test/unit/local_datagram_socket.cpp index f05a09a39..f19780426 100644 --- a/test/unit/local_datagram_socket.cpp +++ b/test/unit/local_datagram_socket.cpp @@ -883,6 +883,46 @@ struct local_datagram_socket_test BOOST_TEST(recv_ec == capy::cond::canceled); } + // Destroy the io_context with a receive completion already queued. + // Both sockets have a datagram waiting while the receives are + // parked, so the one handler the loop dispatches leaves the other + // completion for the scheduler's shutdown to drain. + void testDestroyWithQueuedReceive() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_datagram_socket a1(ioc), b1(ioc), a2(ioc), b2(ioc); + if (auto ec = connect_pair(a1, b1)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(a2, b2)) + throw std::system_error(ec, "connect_pair"); + + char buf1[8], buf2[8]; + local_endpoint from1, from2; + auto reader = [&](local_datagram_socket& s, char* p, + local_endpoint& from) -> capy::task<> { + std::ignore = co_await s.recv_from( + capy::mutable_buffer(p, 8), from); + ++resumed; + }; + capy::run_async(ex)(reader(a1, buf1, from1)); + capy::run_async(ex)(reader(a2, buf2, from2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + + BOOST_TEST(::send(b1.native_handle(), "x", 1, 0) == 1); + BOOST_TEST(::send(b2.native_handle(), "x", 1, 0) == 1); + + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST(before_destroy < 2); + BOOST_TEST_EQ(resumed, before_destroy); + } + void run() { testConstruction(); @@ -912,6 +952,10 @@ struct local_datagram_socket_test testDatagramBoundary(); testRecvPeek(); testRecvFromPeek(); +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithQueuedReceive(); +#endif #ifdef __linux__ testAbstractSocket(); #endif diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index 4288ce9dc..29eabf271 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -1188,6 +1188,147 @@ struct local_stream_socket_test BOOST_TEST_PASS(); } + // Destroy the io_context with a read completion already queued. + // Both pairs are made readable before the loop runs, so the single + // dispatched handler leaves the other completion behind and the + // scheduler's shutdown has to drain it rather than deliver it. + void testDestroyWithQueuedRead() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket a1(ioc), b1(ioc), a2(ioc), b2(ioc); + if (auto ec = connect_pair(a1, b1)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(a2, b2)) + throw std::system_error(ec, "connect_pair"); + + char buf1[8], buf2[8]; + auto reader = [&](local_stream_socket& s, + char* p) -> capy::task<> { + std::ignore = co_await s.read_some( + capy::mutable_buffer(p, 8)); + ++resumed; + }; + capy::run_async(ex)(reader(a1, buf1)); + capy::run_async(ex)(reader(a2, buf2)); + // Two handlers, one per coroutine, park both reads. + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + + BOOST_TEST(::send(b1.native_handle(), "x", 1, 0) == 1); + BOOST_TEST(::send(b2.native_handle(), "x", 1, 0) == 1); + + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST(before_destroy < 2); + BOOST_TEST_EQ(resumed, before_destroy); + } + + // Destroy the io_context with a connect completion already queued. + // Both handshakes finish before the loop dispatches, so one is + // still waiting in the queue when the scheduler shuts down. + void testDestroyWithQueuedConnect() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + test::temp_socket_dir tmp; + + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + auto const ep = local_endpoint(tmp.path()); + BOOST_TEST(!acc.bind(ep)); + BOOST_TEST(!acc.listen()); + + local_stream_socket c1(ioc), c2(ioc); + auto client = [&](local_stream_socket& s) -> capy::task<> { + std::ignore = co_await s.connect(ep); + ++resumed; + }; + capy::run_async(ex)(client(c1)); + capy::run_async(ex)(client(c2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + + // Destroy the io_context with a wait completion already queued. + // Both sockets become readable while the waits are parked, so the + // one handler the loop dispatches leaves the other wait for the + // scheduler's shutdown to drain. + void testDestroyWithQueuedWait() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket a1(ioc), b1(ioc), a2(ioc), b2(ioc); + if (auto ec = connect_pair(a1, b1)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(a2, b2)) + throw std::system_error(ec, "connect_pair"); + + auto waiter = [&](local_stream_socket& s) -> capy::task<> { + std::ignore = co_await s.wait(wait_type::read); + ++resumed; + }; + capy::run_async(ex)(waiter(a1)); + capy::run_async(ex)(waiter(a2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + + BOOST_TEST(::send(b1.native_handle(), "x", 1, 0) == 1); + BOOST_TEST(::send(b2.native_handle(), "x", 1, 0) == 1); + + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST(before_destroy < 2); + BOOST_TEST_EQ(resumed, before_destroy); + } + + // Destroy the io_context with a write completion already queued. + // Only the proactor backends reach that state: a POSIX write to a + // socket with room finishes in the initiator, so there the scope + // ends with nothing outstanding and the test is a no-op. + void testDestroyWithQueuedWrite() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_stream_socket a1(ioc), b1(ioc), a2(ioc), b2(ioc); + if (auto ec = connect_pair(a1, b1)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(a2, b2)) + throw std::system_error(ec, "connect_pair"); + + auto writer = [&](local_stream_socket& s) -> capy::task<> { + std::ignore = + co_await s.write_some(capy::const_buffer("x", 1)); + ++resumed; + }; + capy::run_async(ex)(writer(a1)); + capy::run_async(ex)(writer(a2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + void testAcceptorOnClosedNoOp() { // cancel/close on a never-opened acceptor are no-ops. @@ -1662,6 +1803,10 @@ struct local_stream_socket_test // Abandon parked coroutine frames by design; see context.hpp. testDestroyWithParkedAccept(); testDestroyWithParkedRead(); + testDestroyWithQueuedRead(); + testDestroyWithQueuedConnect(); + testDestroyWithQueuedWait(); + testDestroyWithQueuedWrite(); #endif testAcceptorOnClosedNoOp(); testAcceptorBindClosedThrows(); diff --git a/test/unit/random_access_file.cpp b/test/unit/random_access_file.cpp index 0f3daabd6..ab87be6f8 100644 --- a/test/unit/random_access_file.cpp +++ b/test/unit/random_access_file.cpp @@ -17,6 +17,8 @@ #endif #include +#include +#include #include #include #include @@ -35,6 +37,7 @@ #include #include #include +#include #include @@ -633,6 +636,39 @@ struct random_access_file_test BOOST_TEST_EQ(completed.load(), num_ops); } + // Destroy the io_context with a file the service still owns. The + // file and the parked accept share a coroutine frame that the + // accept never unwinds, so the service reclaims a live implementation + // at shutdown instead of an empty list. + void testDestroyWithLiveFile() + { + temp_file tmp("raf_teardown_", "hello world"); + // Copy out of the anonymous-namespace type: capturing it + // by reference gives the lambda a member whose type has + // internal linkage, which -Wsubobject-linkage rejects. + auto const path = tmp.path; + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + random_access_file f(ioc); + std::ignore = f.open(path, file_base::read_only); + tcp_acceptor acc(ioc); + std::ignore = acc.open(); + std::ignore = acc.bind( + endpoint(ipv4_address::loopback(), 0)); + std::ignore = acc.listen(); + tcp_socket peer(ioc); + std::ignore = co_await acc.accept(peer); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // One handler carries the coroutine to the parked accept. + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } + void run() { testConstruction(); @@ -684,6 +720,11 @@ struct random_access_file_test testReadAtPastEofErrorPath(); testCancelInflightOperation(); testCancelWithStoppedToken(); + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithLiveFile(); +#endif } // Operations on closed file diff --git a/test/unit/resolver.cpp b/test/unit/resolver.cpp index b27c6cf4f..5a05bf9c8 100644 --- a/test/unit/resolver.cpp +++ b/test/unit/resolver.cpp @@ -17,12 +17,16 @@ #endif #include +#include +#include #include #include #include #include +#include +#include "context.hpp" #include "test_suite.hpp" namespace boost::corosio { @@ -1076,6 +1080,33 @@ struct resolver_test BOOST_TEST(converted == ep); } + // Destroy the io_context with a resolver the service still owns. + // The resolver and the parked accept share a coroutine frame that + // the accept never unwinds, so the service reclaims a live + // implementation at shutdown instead of an empty list. + void testDestroyWithLiveResolver() + { + bool resumed = false; + { + io_context ioc; + auto keeper = [&]() -> capy::task<> { + resolver r(ioc); + tcp_acceptor acc(ioc); + std::ignore = acc.open(); + std::ignore = acc.bind( + endpoint(ipv4_address::loopback(), 0)); + std::ignore = acc.listen(); + tcp_socket peer(ioc); + std::ignore = co_await acc.accept(peer); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // One handler carries the coroutine to the parked accept. + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } + void run() { // Construction and move semantics @@ -1140,6 +1171,11 @@ struct resolver_test testReverseFlagsOperators(); testSequentialReverseResolves(); testMixedResolveAndReverseResolve(); + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithLiveResolver(); +#endif } }; diff --git a/test/unit/stream_file.cpp b/test/unit/stream_file.cpp index 36a1c826b..cd2436ee7 100644 --- a/test/unit/stream_file.cpp +++ b/test/unit/stream_file.cpp @@ -17,6 +17,8 @@ #endif #include +#include +#include #include #include #include @@ -35,6 +37,7 @@ #include #include #include +#include #include #include @@ -935,6 +938,39 @@ struct stream_file_test } } + // Destroy the io_context with a file the service still owns. The + // file and the parked accept share a coroutine frame that the + // accept never unwinds, so the service reclaims a live implementation + // at shutdown instead of an empty list. + void testDestroyWithLiveFile() + { + temp_file tmp("sf_teardown_", "hello world"); + // Copy out of the anonymous-namespace type: capturing it + // by reference gives the lambda a member whose type has + // internal linkage, which -Wsubobject-linkage rejects. + auto const path = tmp.path; + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + stream_file f(ioc); + std::ignore = f.open(path, file_base::read_only); + tcp_acceptor acc(ioc); + std::ignore = acc.open(); + std::ignore = acc.bind( + endpoint(ipv4_address::loopback(), 0)); + std::ignore = acc.listen(); + tcp_socket peer(ioc); + std::ignore = co_await acc.accept(peer); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // One handler carries the coroutine to the parked accept. + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } + void run() { testConstruction(); @@ -983,6 +1019,11 @@ struct stream_file_test testAssignOverOpenAdopts(); testSeekNegative(); testCancelWithStoppedToken(); + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithLiveFile(); +#endif } // Cancellation diff --git a/test/unit/tcp_acceptor.cpp b/test/unit/tcp_acceptor.cpp index eedbcb915..a5fa75d53 100644 --- a/test/unit/tcp_acceptor.cpp +++ b/test/unit/tcp_acceptor.cpp @@ -303,6 +303,104 @@ struct tcp_acceptor_test acc.close(); } + // Destroy the io_context with an accept completion already queued. + // Two listeners each have a connection waiting before the accepts + // are posted, so the one handler the loop dispatches leaves the + // other completion for the scheduler's shutdown to drain. + void testDestroyWithQueuedAccept() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc1(ioc), acc2(ioc); + endpoint eps[2]; + tcp_acceptor* accs[2] = {&acc1, &acc2}; + for (int i = 0; i < 2; ++i) + { + BOOST_TEST(!accs[i]->open()); + accs[i]->set_option(socket_option::reuse_address(true)); + BOOST_TEST( + !accs[i]->bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!accs[i]->listen()); + eps[i] = endpoint( + ipv4_address::loopback(), accs[i]->local_endpoint().port()); + } + + tcp_socket c1(ioc), c2(ioc), s1(ioc), s2(ioc); + auto connect_both = [&]() -> capy::task<> { + std::ignore = co_await c1.connect(eps[0]); + std::ignore = co_await c2.connect(eps[1]); + }; + capy::run_async(ex)(connect_both()); + ioc.run(); + ioc.restart(); + + auto accepter = [&](tcp_acceptor& a, + tcp_socket& s) -> capy::task<> { + std::ignore = co_await a.accept(s); + ++resumed; + }; + capy::run_async(ex)(accepter(acc1, s1)); + capy::run_async(ex)(accepter(acc2, s2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + + // Destroy the io_context with an acceptor-wait completion already + // queued. The acceptor wait has a handler of its own, distinct from + // the accept's, and only its shutdown arm is reached this way. + void testDestroyWithQueuedAcceptorWait() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc1(ioc), acc2(ioc); + endpoint eps[2]; + tcp_acceptor* accs[2] = {&acc1, &acc2}; + for (int i = 0; i < 2; ++i) + { + BOOST_TEST(!accs[i]->open()); + accs[i]->set_option(socket_option::reuse_address(true)); + BOOST_TEST( + !accs[i]->bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!accs[i]->listen()); + eps[i] = endpoint( + ipv4_address::loopback(), accs[i]->local_endpoint().port()); + } + + tcp_socket c1(ioc), c2(ioc); + auto connect_both = [&]() -> capy::task<> { + std::ignore = co_await c1.connect(eps[0]); + std::ignore = co_await c2.connect(eps[1]); + }; + capy::run_async(ex)(connect_both()); + ioc.run(); + ioc.restart(); + + auto waiter = [&](tcp_acceptor& a) -> capy::task<> { + std::ignore = co_await a.wait(wait_type::read); + ++resumed; + }; + capy::run_async(ex)(waiter(acc1)); + capy::run_async(ex)(waiter(acc2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + // Destroy the io_context with a read still parked on a connected // socket; service shutdown must drain the abandoned operation // without resuming it. @@ -1742,6 +1840,8 @@ struct tcp_acceptor_test // Abandon parked coroutine frames by design; see context.hpp. testDestroyWithParkedAccept(); testDestroyWithParkedRead(); + testDestroyWithQueuedAccept(); + testDestroyWithQueuedAcceptorWait(); #endif // IPv6 diff --git a/test/unit/tcp_socket.cpp b/test/unit/tcp_socket.cpp index 6735c8b89..c4934aaf1 100644 --- a/test/unit/tcp_socket.cpp +++ b/test/unit/tcp_socket.cpp @@ -1710,6 +1710,99 @@ struct tcp_socket_test s2.close(); } + // Destroy the io_context with a write completion already queued. + // The loop dispatches one handler, so any completion that arrived + // with it is left for the scheduler's shutdown to drain. + void testDestroyWithQueuedWrite() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [a1, b1] = + test::make_socket_pair(ioc); + auto [a2, b2] = + test::make_socket_pair(ioc); + + auto writer = [&](tcp_socket& s) -> capy::task<> { + std::ignore = + co_await s.write_some(capy::const_buffer("x", 1)); + ++resumed; + }; + capy::run_async(ex)(writer(a1)); + capy::run_async(ex)(writer(a2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + + // The connect twin: two handshakes finish before the loop + // dispatches, so one of them is still queued at destruction. + void testDestroyWithQueuedConnect() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + acc.set_option(socket_option::reuse_address(true)); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + auto const ep = endpoint( + ipv4_address::loopback(), acc.local_endpoint().port()); + + tcp_socket c1(ioc), c2(ioc); + auto client = [&](tcp_socket& s) -> capy::task<> { + std::ignore = co_await s.connect(ep); + ++resumed; + }; + capy::run_async(ex)(client(c1)); + capy::run_async(ex)(client(c2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + + // The wait twin: both sockets are readable before the loop runs. + void testDestroyWithQueuedWait() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [a1, b1] = + test::make_socket_pair(ioc); + auto [a2, b2] = + test::make_socket_pair(ioc); + + auto waiter = [&](tcp_socket& s) -> capy::task<> { + std::ignore = co_await s.wait(wait_type::read); + ++resumed; + }; + capy::run_async(ex)(waiter(a1)); + capy::run_async(ex)(waiter(a2)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + + BOOST_TEST(::send(b1.native_handle(), "x", 1, 0) == 1); + BOOST_TEST(::send(b2.native_handle(), "x", 1, 0) == 1); + + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + void run() { testConstruction(); @@ -1806,6 +1899,13 @@ struct tcp_socket_test testRelease(); testReleaseClosedThrows(); testAssignV6(); + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithQueuedWrite(); + testDestroyWithQueuedConnect(); + testDestroyWithQueuedWait(); +#endif } void testConnectV6() diff --git a/test/unit/udp_socket.cpp b/test/unit/udp_socket.cpp index a725ed7e9..691ac1e8c 100644 --- a/test/unit/udp_socket.cpp +++ b/test/unit/udp_socket.cpp @@ -1650,6 +1650,54 @@ struct udp_socket_test BOOST_TEST(done); } + // Destroy the io_context with datagram completions already queued. + // Both receives are parked before either datagram is sent, so the + // one handler the loop dispatches leaves the rest of that batch + // for the scheduler's shutdown to drain. + void testDestroyWithQueuedDatagrams() + { + int resumed = 0; + int before_destroy = 0; + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + udp_socket r1(ioc), r2(ioc), s1(ioc), s2(ioc); + BOOST_TEST(!r1.open()); + BOOST_TEST(!r2.open()); + BOOST_TEST(!s1.open()); + BOOST_TEST(!s2.open()); + BOOST_TEST(!r1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!r2.bind(endpoint(ipv4_address::loopback(), 0))); + + auto const ep1 = r1.local_endpoint(); + auto const ep2 = r2.local_endpoint(); + + char b1[8], b2[8]; + endpoint from1, from2; + auto reader = [&](udp_socket& s, char* p, + endpoint& from) -> capy::task<> { + std::ignore = co_await s.recv_from( + capy::mutable_buffer(p, 8), from); + ++resumed; + }; + auto writer = [&](udp_socket& s, endpoint dest) -> capy::task<> { + std::ignore = + co_await s.send_to(capy::const_buffer("x", 1), dest); + ++resumed; + }; + capy::run_async(ex)(reader(r1, b1, from1)); + capy::run_async(ex)(reader(r2, b2, from2)); + capy::run_async(ex)(writer(s1, ep1)); + capy::run_async(ex)(writer(s2, ep2)); + // Four handlers start the four coroutines; the fifth + // delivers one completion out of the batch they produce. + for (int i = 0; i < 5; ++i) + std::ignore = ioc.run_one(); + before_destroy = resumed; + } + BOOST_TEST_EQ(resumed, before_destroy); + } + void run() { testConstruction(); @@ -1703,6 +1751,11 @@ struct udp_socket_test testRelease(); testReleaseClosedThrows(); testAssignV6(); + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testDestroyWithQueuedDatagrams(); +#endif } }; From 4157a486ff148e89c0e8510e29bea8c3a50b4ca6 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 28 Aug 2026 17:52:29 +0200 Subject: [PATCH 27/34] fix(iocp): ask the wait reactor for a bit this provider implements events_for_wait mapped wait_type::error to POLLPRI, which the Microsoft Winsock provider does not implement and refuses the whole WSAPoll call for. One error wait therefore ended the reactor's polling loop for the entire io_context: it marked itself dead, drained every op it held as aborted, and refused every later register_wait, so wait(wait_type::write) on that context came back canceled from then on. Measured on CI, not inferred -- the poll returns SOCKET_ERROR for a set carrying the bit. Ask for POLLRDBAND instead. It carries the same out-of-band meaning, the provider implements it, and the error conditions an error wait is really after arrive in revents whether or not they were asked for, so a peer reset answers the wait as it does on the reactor backends. A poll refused on account of one descriptor no longer takes the other registrations with it either: the reactor asks about each entry alone, answers the ones the provider will not take with the refusal, and keeps polling the rest. A socket closed under a parked wait reaches that path in ordinary use, since the reactor is told to drop the entry one pass after the handle is already gone. A failure no entry accounts for still ends the loop, and the ops it was holding are now told what happened rather than that someone cancelled them. So is every wait registered afterwards: the reactor latches the code it died of and answers with it, so a caller learns why its waits can no longer be served instead of hearing that something cancelled them. canceled is left to the ops close() and cancel() flag, and to the ops a stop() drain finds parked. Tests: the fault suite asks the provider directly what it makes of the bit the reactor registers with, then asserts the contract -- a reset answers the error wait, and a wait registered afterwards is answered too -- and a new test closes a descriptor under a parked wait to show the refusal costs that wait alone. The templated tcp_socket suite gains the cross-backend half: an error wait, then a wait that has to work. --- doc/error-handling-rulebook.md | 4 + .../native/detail/iocp/win_wait_reactor.hpp | 141 +++++++--- test/unit/fault/iocp_faults.cpp | 250 ++++++++++++------ test/unit/tcp_socket.cpp | 90 +++++++ 4 files changed, 373 insertions(+), 112 deletions(-) diff --git a/doc/error-handling-rulebook.md b/doc/error-handling-rulebook.md index 077713e39..1230689d5 100644 --- a/doc/error-handling-rulebook.md +++ b/doc/error-handling-rulebook.md @@ -212,6 +212,10 @@ second channel: `capy::cond::eof`, `capy::cond::canceled` (a stop token, not `errc::operation_canceled`), `capy::cond::timeout` (our deadline, not a kernel `ETIMEDOUT`). +- A background thread that dies mid-flight owes one answer, not two: + the error that killed it, latched, both to the operations it was + holding and to the ones that arrive afterwards. `canceled` is a + stop token and belongs only to operations something cancelled. ## 8. Testing 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 0e3aec1df..ed35045d0 100644 --- a/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp +++ b/include/boost/corosio/native/detail/iocp/win_wait_reactor.hpp @@ -69,11 +69,28 @@ namespace boost::corosio::detail { 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 + table polling. Such an error reaches the caller only where nothing + flagged the op first: close() and cancel() set the cancelled flag + before the handle goes, and a flagged op yields canceled whatever + the completion carries. + + A refusal none of the entries accounts for is the end of the + reactor: the error is latched, the ops it still holds are completed + with it, and every later register_wait is completed with it too, + since nothing would drain an op parked after that point. Both + answers name the reason waits can no longer be served; canceled is + reserved for ops close() or cancel() flagged, and for the ops a + stop() drain finds parked. + The constructor builds the wakeup channel and throws if it cannot: a reactor that cannot be woken can never report readiness, so there is no reactor worth handing back. The polling thread is a separate cost, paid by the first register_wait, so a context that - never waits never carries one. + never waits never carries one. A thread the system refuses costs + only the wait that asked for it: that wait completes with + `resource_unavailable_try_again` and the next one tries again. Thread-safe: register_wait, cancel_wait, and stop may be called from any thread. @@ -118,6 +135,7 @@ class win_wait_reactor : private win_wsa_init }; void run(); + bool drop_refused_entries(); DWORD queue_register(entry const& e); void wake_self() noexcept; DWORD make_wakeup_pair() noexcept; @@ -125,7 +143,7 @@ class win_wait_reactor : private win_wsa_init // A failed call that left a zero last error would answer "no // error" and put the reactor straight back on the silent path. - static DWORD wakeup_error() noexcept + static DWORD last_error() noexcept { DWORD const err = ::WSAGetLastError(); return err != 0 ? err : static_cast(WSAEINVAL); @@ -137,7 +155,13 @@ class win_wait_reactor : private win_wsa_init { case wait_type::read: return POLLRDNORM; case wait_type::write: return POLLWRNORM; - default: return POLLPRI; + // The Microsoft provider does not implement POLLPRI and + // refuses the whole call when it is asked for, which would + // take every other registration in the set with it. The band + // it does implement carries the same out-of-band meaning, and + // the error conditions an error wait is really after arrive in + // revents whether or not they were asked for. + default: return POLLRDBAND; } } @@ -151,7 +175,7 @@ class win_wait_reactor : private win_wsa_init case wait_type::write: return (revents & (POLLWRNORM | POLLWRBAND | err_bits)) != 0; default: - return (revents & (POLLPRI | err_bits)) != 0; + return (revents & (POLLRDBAND | err_bits)) != 0; } } @@ -169,13 +193,15 @@ class win_wait_reactor : private win_wsa_init std::atomic stop_{false}; std::atomic wake_pending_{false}; - // Set by the polling thread on its way out, guarded by mutex_. - // stop() is the ordinary way the thread leaves and has stop_ to - // announce it; this covers the thread leaving on its own after a - // WSAPoll error, which stop_ must not be used for -- stop() reads - // it as "already stopped" and would skip the join that keeps the - // thread from being destroyed joinable. - bool dead_ = false; + // What the exit told the ops it was still holding, kept so a later + // register_wait can be told the same thing instead of inventing a + // cancellation nobody asked for. Non-zero is also what says the + // polling thread left on its own after a WSAPoll error: stop_ must + // not be used for that exit -- stop() reads it as "already + // stopped" and would skip the join that keeps the thread from + // being destroyed joinable. Nothing latches a zero here, because + // a failed call that left a zero last error is substituted for. + DWORD dead_err_ = 0; std::vector registered_; // reactor-thread-only @@ -215,7 +241,7 @@ win_wait_reactor::make_wakeup_pair() noexcept // anything: closesocket() overwrites it. SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listener == INVALID_SOCKET) - return wakeup_error(); + return last_error(); sockaddr_in addr{}; addr.sin_family = AF_INET; @@ -229,7 +255,7 @@ win_wait_reactor::make_wakeup_pair() noexcept ::getsockname(listener, reinterpret_cast(&addr), &len) == SOCKET_ERROR) { - DWORD const err = wakeup_error(); + DWORD const err = last_error(); ::closesocket(listener); return err; } @@ -237,7 +263,7 @@ win_wait_reactor::make_wakeup_pair() noexcept wakeup_write_ = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (wakeup_write_ == INVALID_SOCKET) { - DWORD const err = wakeup_error(); + DWORD const err = last_error(); ::closesocket(listener); return err; } @@ -246,7 +272,7 @@ win_wait_reactor::make_wakeup_pair() noexcept wakeup_write_, reinterpret_cast(&addr), len) == SOCKET_ERROR) { - DWORD const err = wakeup_error(); + DWORD const err = last_error(); ::closesocket(wakeup_write_); wakeup_write_ = INVALID_SOCKET; ::closesocket(listener); @@ -256,7 +282,7 @@ win_wait_reactor::make_wakeup_pair() noexcept wakeup_read_ = ::accept(listener, nullptr, nullptr); if (wakeup_read_ == INVALID_SOCKET) { - DWORD const err = wakeup_error(); + DWORD const err = last_error(); ::closesocket(listener); ::closesocket(wakeup_write_); wakeup_write_ = INVALID_SOCKET; @@ -270,7 +296,7 @@ win_wait_reactor::make_wakeup_pair() noexcept u_long non_blocking = 1; if (::ioctlsocket(wakeup_read_, FIONBIO, &non_blocking) == SOCKET_ERROR) { - DWORD const err = wakeup_error(); + DWORD const err = last_error(); close_wakeup_pair(); return err; } @@ -331,9 +357,9 @@ win_wait_reactor::register_wait( if (DWORD const err = queue_register(entry{fd, w, op}); err != 0) { - // The reactor is stopped, or its polling thread has died, so - // nothing would ever drain a parked op. Report the abort its - // own shutdown drain gives the ops it was still holding. + // Nothing would ever drain a parked op, and the refusal knows + // why: the abort a stop drains with, the error the polling + // thread died of, or the system declining a thread. sched_.on_completion(op, err, 0); return; } @@ -351,11 +377,16 @@ win_wait_reactor::queue_register(entry const& e) // which ends the process, and an op queued after it would have no // drainer. Queueing under the flag instead leaves the op for the // drain run() performs on its way out. - // dead_ says the same thing for the other exit: a thread that left - // on a WSAPoll error drained what it held and will not poll again, - // so a register queued after it would wait on nobody. - if (stop_.load(std::memory_order_acquire) || dead_) + // dead_err_ says the same thing for the other exit: a thread that + // left on a WSAPoll error drained what it held and will not poll + // again, so a register queued after it would wait on nobody. It + // answers with what killed it, so the caller learns why its waits + // stopped being served instead of hearing that something cancelled + // them. + if (stop_.load(std::memory_order_acquire)) return ERROR_OPERATION_ABORTED; + if (dead_err_) + return dead_err_; // A polling thread costs a thread per context, and a context that // never waits never pays for one; the first wait is what starts it. @@ -389,7 +420,7 @@ win_wait_reactor::cancel_wait(overlapped_op* op) // has nothing to cancel either -- the drain those exits run // already answered whatever was parked, and a register that // arrived after them was refused at its caller. - if (stop_.load(std::memory_order_acquire) || dead_) + if (stop_.load(std::memory_order_acquire) || dead_err_) return; pending_cancel_.push_back(op); } @@ -415,11 +446,42 @@ win_wait_reactor::stop() t.join(); } +inline bool +win_wait_reactor::drop_refused_entries() +{ + // A descriptor the provider no longer recognises either comes back + // POLLNVAL on its own entry, which the revents walk already + // answers, or refuses the whole call and reaches here. Asking + // about each entry alone covers both without depending on which + // one this provider does. A socket closed under a parked wait is + // the ordinary way to get here: the reactor is told to drop the + // entry, but the handle can go before that ask is read. + bool dropped = false; + for (std::size_t i = registered_.size(); i > 0; --i) + { + auto const& e = registered_[i - 1]; + WSAPOLLFD pfd{e.fd, events_for_wait(e.w), 0}; + if (::WSAPoll(&pfd, 1, 0) != SOCKET_ERROR) + continue; + + sched_.on_completion(e.op, last_error(), 0); + registered_.erase(registered_.begin() + (i - 1)); + dropped = true; + } + return dropped; +} + inline void win_wait_reactor::run() { std::vector pollfds; + // What the ops still parked here are told on the way out, and what + // every later register_wait is told too. Ending on a poll the + // provider refused is not a cancellation, and an op that reports + // one hides the reason its wait could not be kept. + DWORD drain_err = ERROR_OPERATION_ABORTED; + while (!stop_.load(std::memory_order_acquire)) { // Drain pending register/cancel under the lock. @@ -480,7 +542,19 @@ win_wait_reactor::run() static_cast(pollfds.size()), -1 /* infinite */); if (n == SOCKET_ERROR) - break; + { + // A refusal one entry accounts for costs that entry its + // wait and nothing else; the rest of the set keeps being + // polled, and a later register still finds a live reactor. + // Only a failure no entry explains ends the loop. + DWORD const err = last_error(); + if (registered_.empty() || !drop_refused_entries()) + { + drain_err = err; + break; + } + continue; + } // Drain the wakeup socket so it stops reporting readable. if (pollfds[0].revents != 0) @@ -532,24 +606,25 @@ win_wait_reactor::run() } } - // Drain remaining ops as cancelled on shutdown. This must cover - // both the active set and anything still queued by user threads - // that hasn't been moved into registered_ yet, otherwise those - // ops leak work_started credit and stall scheduler shutdown. + // Drain remaining ops on the way out. This must cover both the + // active set and anything still queued by user threads that hasn't + // been moved into registered_ yet, otherwise those ops leak + // work_started credit and stall scheduler shutdown. { std::lock_guard lock(mutex_); // Closing the door and taking what is behind it in one critical // section is what leaves no register in between: one that got // in is drained here, one that arrives after is refused by - // queue_register and completes as aborted at its caller. - dead_ = true; + // queue_register and completes with the same code at its + // caller. + dead_err_ = drain_err; for (auto& e : pending_register_) registered_.push_back(e); pending_register_.clear(); pending_cancel_.clear(); } for (auto& e : registered_) - sched_.on_completion(e.op, ERROR_OPERATION_ABORTED, 0); + 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 94a9a61ae..2520bf48e 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -97,13 +97,15 @@ capy::task<> stop_guard(io_context& ioc, bool& expired) // Wait until the provider reports the error condition on `s`, and say // whether it ever did. The wait reactor keys off the same bits, so a // round that never carried them is a premise the caller did not have -// rather than a failure of the code under test. -bool wait_for_poll_error(tcp_socket& s) +// rather than a failure of the code under test. `events` is what the +// reactor would have asked for, since whether the error bits come back +// at all is the provider's answer to that question and not to another. +bool wait_for_poll_error(tcp_socket& s, SHORT events) { constexpr SHORT err_bits = POLLERR | POLLHUP | POLLNVAL; WSAPOLLFD pfd{}; pfd.fd = static_cast(s.native_handle()); - pfd.events = POLLWRNORM; + pfd.events = events; for(int i = 0; i < 200; ++i) { pfd.revents = 0; @@ -113,6 +115,25 @@ bool wait_for_poll_error(tcp_socket& s) return false; } +// Say what the provider makes of `s` for each mask an error wait +// could register with. Only reached where the mask the reactor uses +// carried no error condition, so a round that has to choose another +// one is not blind. +void report_poll_masks(tcp_socket& s) +{ + SHORT const masks[] = {0, POLLRDNORM, POLLWRNORM, POLLRDBAND}; + for(SHORT m : masks) + { + WSAPOLLFD pfd{static_cast(s.native_handle()), m, 0}; + ::WSASetLastError(0); + int const n = ::WSAPoll(&pfd, 1, 0); + std::fprintf(stderr, + "fault harness: events %d -> %d, revents %d, error %d\n", + static_cast(m), n, static_cast(pfd.revents), + ::WSAGetLastError()); + } +} + // Run into the reset, so the socket has a chance to record it where // SO_ERROR will report it. A connection the provider has flagged // through the poll does not necessarily have an error waiting there @@ -1046,11 +1067,10 @@ struct iocp_faults s.close(); } // And a context whose wakes all failed still answers a wait - // rather than leaving it outstanding. What ends this one it - // does not say: an error wait resolves as cancelled whether - // the cancel below reached the reactor or the reactor drained - // it on the way out, and nothing here is armed to tell those - // apart -- testErrorWaitOnThisProvider is what asks which. + // rather than leaving it outstanding. Nothing raises the error + // condition on a healthy pair, so the cancel below is the only + // thing that can end this wait: a reactor whose wakes were + // swallowed would leave it parked. io_context ioc(iocp); auto pair = make_socket_pair(ioc); auto& s1 = pair.first; @@ -1114,15 +1134,18 @@ struct iocp_faults ioc.run(); BOOST_TEST(!expired); BOOST_TEST(arm.has_value() && arm->fired()); - BOOST_TEST(parked_ec == capy::error::canceled); + // No entry of the poll set accounts for WSAENOBUFS, so the + // reactor leaves for good -- and the op it was holding is told + // what happened rather than that someone cancelled it. + BOOST_TEST(parked_ec == win_err(WSAENOBUFS)); arm.reset(); // The polling thread left for good and nothing restarts it, so // a wait registered afterwards has nobody to report its // readiness. Refusing it is the only answer that is not a park - // forever, and it is the same abort the drain on the way out - // gave the ops the reactor was holding. io_context::stop() does - // not reach the reactor, so this is the reactor's own state + // forever, and the refusal names what killed the thread rather + // than claiming a cancellation. io_context::stop() does not + // reach the reactor, so this is the reactor's own state // answering and not a stopped scheduler. ioc.restart(); std::error_code late_ec; @@ -1137,7 +1160,7 @@ struct iocp_faults capy::run_async(ioc.get_executor())(stop_guard(ioc, late_expired)); ioc.run(); BOOST_TEST(!late_expired); - BOOST_TEST(late_ec == capy::error::canceled); + BOOST_TEST(late_ec == win_err(WSAENOBUFS)); s1.close(); s2.close(); } @@ -1469,10 +1492,10 @@ struct iocp_faults the condition in hand and nothing here depends on a round landing between two coroutines. - A write wait rather than an error wait, and the second half - reports no error at all rather than WSAECONNABORTED, because - `events_for_wait(wait_type::error)` asks WSAPoll for POLLPRI -- - see the report for what this round found that costs. + A write wait, so the second half reports no error at all rather + than WSAECONNABORTED: the substitution the probe falls through + to is for error waits only, and this is the probe's own answer + that is under test. */ void testWaitReactorErrorProbe() { @@ -1490,7 +1513,7 @@ struct iocp_faults // so this is a reset rather than an orderly shutdown // and it leaves SO_ERROR set on the survivor. s2.close(); - premise = wait_for_poll_error(s1); + premise = wait_for_poll_error(s1, POLLWRNORM); touch_after_reset(s1); auto [ec] = co_await s1.wait(wait_type::write); wec = ec; @@ -1529,7 +1552,7 @@ struct iocp_faults auto body = [&]() -> capy::task<> { s2.close(); - premise = wait_for_poll_error(s1); + premise = wait_for_poll_error(s1, POLLWRNORM); // The probe runs on the reactor's polling thread, so the // arm has to be process-wide; nothing else calls // getsockopt while it is up. @@ -1557,18 +1580,15 @@ struct iocp_faults s1.close(); } - /* What this provider makes of an error wait. - - events_for_wait asks WSAPoll for POLLPRI on wait_type::error - (win_wait_reactor.hpp:133-141), and a poll the provider refuses - ends the reactor's loop for the whole context: it sets dead_ and - drains everything it holds as aborted (:471-472, :534, :541), - after which register_wait refuses the next comer (:333-340). - Which of those two worlds this runner is in is the provider's - answer and not the library's, so it is recorded rather than - asserted -- the branch a run took is legible in the coverage of - this test. What is asserted either way is that neither answer - leaves an operation outstanding. + /* An error wait is answered, and costs the context nothing. + + events_for_wait used to ask WSAPoll for POLLPRI, which this + provider does not implement and refuses the whole call for: the + reactor left its loop, drained every op it held and refused + every later register, so one error wait disabled waiting on the + context for good. The bit it asks for now has to be one the + provider takes, and the error condition an error wait is after + arrives in revents either way. */ void testErrorWaitOnThisProvider() { @@ -1576,85 +1596,156 @@ struct iocp_faults // The provider on its own, with none of the library in it. io_context ioc(iocp); auto pair = make_socket_pair(ioc); - WSAPOLLFD pfd{}; - pfd.fd = static_cast(pair.first.native_handle()); - pfd.events = POLLPRI; + auto const fd = + static_cast(pair.first.native_handle()); + WSAPOLLFD pfd{fd, POLLRDBAND, 0}; ::WSASetLastError(0); int const n = ::WSAPoll(&pfd, 1, 0); int const err = ::WSAGetLastError(); if(n == SOCKET_ERROR) { + // The other bit an error wait could register with, + // named here so a round that has to choose again is + // not blind. + WSAPOLLFD quiet{fd, 0, 0}; + ::WSASetLastError(0); + int const qn = ::WSAPoll(&quiet, 1, 0); std::fprintf(stderr, - "fault harness: WSAPoll refuses POLLPRI with %d\n", - err); - BOOST_TEST(err != 0); - } - else - { - std::fprintf(stderr, - "fault harness: WSAPoll accepts POLLPRI, %d ready, " - "revents %d\n", n, static_cast(pfd.revents)); - // Accepted or ignored, the bit must not cost a live - // socket its validity. - BOOST_TEST((pfd.revents & POLLNVAL) == 0); + "fault harness: WSAPoll refuses POLLRDBAND with " + "%d; asked for nothing it answers %d, revents %d, " + "error %d\n", + err, qn, static_cast(quiet.revents), + ::WSAGetLastError()); } + BOOST_TEST(n != SOCKET_ERROR); + // Accepted or ignored, the bit must not cost a live socket + // its validity. + BOOST_TEST((pfd.revents & POLLNVAL) == 0); pair.first.close(); pair.second.close(); } - // And what registering one costs the context: a write wait on - // a healthy socket is answered by a live reactor and refused - // by a dead one. + // The contract: a reset answers the error wait, and a wait + // registered afterwards is answered too. Both pairs are built + // before the run, since make_socket_pair runs the context. io_context ioc(iocp); - auto pair = make_socket_pair(ioc); + auto pair = make_socket_pair(ioc); + auto healthy = make_socket_pair(ioc); auto& s1 = pair.first; auto& s2 = pair.second; std::error_code eec, wec; - bool done_err = false; - bool done_w = false; - bool expired = false; - auto error_wait = [&]() -> capy::task<> + bool premise = false; + bool expired = false; + auto body = [&]() -> capy::task<> { + // Zero linger on both ends, so this is a reset and not an + // orderly shutdown. + s2.close(); + premise = wait_for_poll_error(s1, POLLRDBAND); + if(!premise) + { + report_poll_masks(s1); + ioc.stop(); + co_return; + } + auto [ec] = co_await s1.wait(wait_type::error); + eec = ec; + auto [wc] = co_await healthy.first.wait(wait_type::write); + wec = wc; + 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(premise); + // The reset itself, or the substitution the reactor makes when + // SO_ERROR has nothing to add -- but never a cancellation, + // which nothing here cancelled. + BOOST_TEST(eec); + BOOST_TEST(eec != capy::error::canceled); + BOOST_TEST(!wec); + s1.close(); + healthy.first.close(); + healthy.second.close(); + } + + /* A descriptor the provider refuses costs its own wait and no + other. + + WSAPoll validates the whole array before it polls any of it, so + one socket it no longer recognises refuses the call for every + registration in the set. close_socket asks the reactor to drop + the entry before it closes the handle, but the ask is read on + the reactor's next pass and the handle is gone before then, so + a poll carrying the dead descriptor is reachable in ordinary + use (win_tcp_socket_internal::close_socket says so). + + Closed behind the library's back rather than through a + fault_scope: the reactor answers the refusal by asking about + each entry on its own, so the arm would have to fail the set + poll and that entry's own poll, and the harness arms one call + ordinal at a time. + */ + void testWaitReactorEntryRefused() + { + io_context ioc(iocp); + // Both pairs exist before anything is closed, so no socket id + // freed below can come back as one of these. + auto pair = make_socket_pair(ioc); + auto healthy = make_socket_pair(ioc); + auto& s1 = pair.first; + auto& s2 = pair.second; + std::error_code eec, wec, lec; + bool done_err = false; + bool done_late = false; + bool expired = false; + auto parked = [&]() -> capy::task<> + { + // Nothing raises the error condition on a healthy pair, so + // this wait stays parked while the handle under it goes. auto [ec] = co_await s1.wait(wait_type::error); eec = ec; done_err = true; - if(done_w) + if(done_late) ioc.stop(); }; - auto write_wait = [&]() -> capy::task<> + auto breaker = [&]() -> capy::task<> { - auto [ec] = co_await s2.wait(wait_type::write); - wec = ec; - done_w = true; - // Nothing raises the error condition on s1, so a reactor - // still polling has to be told to let that wait go. - s1.cancel(); + ::closesocket(static_cast(s1.native_handle())); + // A register is what makes the reactor rebuild its poll + // set around the entry that is now gone. + auto [wc] = co_await healthy.first.wait(wait_type::write); + wec = wc; + // And one more afterwards: a reactor that survived the + // refusal still takes registrations. + auto [lc] = co_await healthy.second.wait(wait_type::write); + lec = lc; + done_late = true; if(done_err) ioc.stop(); }; - capy::run_async(ioc.get_executor())(error_wait()); - capy::run_async(ioc.get_executor())(write_wait()); + capy::run_async(ioc.get_executor())(parked()); + capy::run_async(ioc.get_executor())(breaker()); capy::run_async(ioc.get_executor())(stop_guard(ioc, expired)); ioc.run(); BOOST_TEST(!expired); BOOST_TEST(done_err); - BOOST_TEST(done_w); - BOOST_TEST(eec == capy::error::canceled); - if(wec == capy::error::canceled) - { - // Refused rather than answered: the reactor was already - // gone when this wait asked to register. - std::fprintf(stderr, - "fault harness: a write wait was refused after an error " - "wait had been registered\n"); - BOOST_TEST(s2.is_open()); - } - else - { - BOOST_TEST(!wec); - } + BOOST_TEST(done_late); + // Whether the provider refused the call or flagged the entry + // POLLNVAL, the wait on the dead descriptor is answered with + // an error of its own and not with the abort a drained reactor + // hands out. + BOOST_TEST(eec); + BOOST_TEST(eec != capy::error::canceled); + BOOST_TEST(!wec); + BOOST_TEST(!lec); + // s1's handle is already gone; this closes a socket id no + // later call of this test's own asked for. s1.close(); s2.close(); + healthy.first.close(); + healthy.second.close(); } /* A context whose extension-pointer bootstrap never ran. @@ -1910,6 +2001,7 @@ struct iocp_faults testWaitReactorPollFails(); testWaitReactorErrorProbe(); testErrorWaitOnThisProvider(); + testWaitReactorEntryRefused(); testLocalSetupFails(); testLocalConnectAcceptFails(); testLocalIoFails(); diff --git a/test/unit/tcp_socket.cpp b/test/unit/tcp_socket.cpp index c4934aaf1..ab956f8a5 100644 --- a/test/unit/tcp_socket.cpp +++ b/test/unit/tcp_socket.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #if BOOST_COROSIO_POSIX #include // getpid() @@ -895,6 +896,94 @@ struct tcp_socket_test s2.close(); } + // An error wait must not cost the context its readiness + // machinery. What a platform makes of the reset itself differs -- + // select reports no exceptional condition for one -- but a wait + // registered afterwards has to be answered either way. + void testWaitForErrorThenWait() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + // The default pair lingers zero, so close() sends RST and + // leaves an error condition behind on the survivor. + auto [s1, s2] = test::make_socket_pair(ioc); + auto [s3, s4] = test::make_socket_pair(ioc); + + std::error_code wait_ec; + bool wait_done = false; + + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::error); + wait_ec = ec; + wait_done = true; + }; + auto closer = [&]() -> capy::task<> { + s2.close(); + // Bound the wait: a backend that does not report a reset + // as an error condition would park it for good. Stepped, + // so a backend that reports it pays one step and not the + // whole bound. + for(int i = 0; i < 20 && !wait_done; ++i) + { + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(10)); + } + if(!wait_done) + s1.cancel(); + }; + + capy::run_async(ex)(waiter()); + capy::run_async(ex)(closer()); + ioc.run(); + BOOST_TEST(wait_done); +#if BOOST_COROSIO_HAS_SELECT + // select's exceptional set carries out-of-band data and not a + // reset, so on that backend the bound above is what ends the + // wait and there is nothing to insist on. + constexpr bool is_select = std::is_same_v< + std::remove_const_t, select_t>; +#else + constexpr bool is_select = false; +#endif +#if BOOST_COROSIO_HAS_IO_URING + constexpr bool is_uring = std::is_same_v< + std::remove_const_t, io_uring_t>; +#else + constexpr bool is_uring = false; +#endif + if constexpr (!is_select) + { + // Never the cancel: that would say the reset reached + // nothing and the bound is what ended the wait. + BOOST_TEST(wait_ec != capy::cond::canceled); + // Whether the completion also names the error is the + // backend's own: the reactors read it out of SO_ERROR and + // IOCP substitutes an abort where that reads zero, while + // io_uring reports the poll's readiness with no error. + if constexpr (!is_uring) + BOOST_TEST(wait_ec); + } + + // The wait above is what the readiness machinery had to + // survive; this one is what says it did. + ioc.restart(); + std::error_code write_ec; + bool write_done = false; + auto writer = [&]() -> capy::task<> { + auto [ec] = co_await s3.wait(wait_type::write); + write_ec = ec; + write_done = true; + }; + capy::run_async(ex)(writer()); + ioc.run(); + BOOST_TEST(write_done); + BOOST_TEST(!write_ec); + + s1.close(); + s3.close(); + s4.close(); + } + // Composed Operations void testReadFull() @@ -1846,6 +1935,7 @@ struct tcp_socket_test testCancelRead(); testCloseWhileReading(); testStopTokenCancellation(); + testWaitForErrorThenWait(); // Socket options testNoDelay(); From 448d20445a5497942f1d69941b6def10ee75dc8f Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 28 Aug 2026 20:06:15 +0200 Subject: [PATCH 28/34] fix(pool): join the blocking-I/O workers before the scheduler drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pool completion posted after the scheduler had drained its queue was neither run nor destroyed. The operation's keepalive on the file or resolver implementation, and the coroutine frame waiting on it, leaked. Services shut down newest first, and everything a scheduler's constructor nests inside itself is older than that scheduler. The file, random-access-file and resolver services created the pool from their own constructors, so the pool was older still and joined its workers only after the drain had run: the item the last worker executed posted into a queue nothing would ever read again. The io_context now creates the pool once the backend is constructed, and those services bind to it on first use rather than in their constructors. That puts the pool after the scheduler in the shutdown walk, so the workers are joined and whatever they posted on the way out is drained. Reordering the services instead would move the guarantee away from the thing that has to be quiet before the drain — the pool, not its callers — and would leave the configured pool size nowhere to be created, since a service constructed before the scheduler cannot be the one the options size. What the walk needs in that position is the service, not its threads. Starting them with it would park a worker in every process that never opens a file and never resolves a name, so the constructor now records the size and nothing else, and the workers start on the first post. Starting a thread can fail, and post() runs on an initiator's thread, where nothing may throw. It answers with a code instead, and the initiator completes the operation with it there and then: the refusal is known before any part of the operation has gone cross-thread, so it takes the exit the closed-descriptor and zero-length contracts already take a few lines above it rather than marking the operation cancelled and posting a completion back through the scheduler to undo the lie. A system that will not give the pool a worker is reported as the error it is, never as the cancellation a stop token means. A pool that is shutting down answers with that cancellation, which is what its callers have reported for the refusal all along. A start that yields fewer threads than asked for still yields a pool that runs everything posted to it, so only a start that yields none refuses the post; a pool short of workers never tries for the rest again, since topping it up would put a thread creation on the initiator's path for every operation after a refusal. The pool service is symbol-visible for the same reason. A service is keyed by its type, and binding on first use moves that lookup out of the compiled library and into whichever translation unit instantiated the initiator; hidden behind a shared library boundary the type would be two types, and the second module would create a pool of its own. The pool's destructor joins as a backstop. The context's walk stays the normal path; a pool created after that walk never gets a `shutdown()` at all, and joinable threads left in one would terminate the process. The resolver's worker dropped its implementation keepalive as soon as it posted, which was harmless only while nothing read the queue again. It now hands the keepalive to the completion, as the file operations already do, and releases it on the drain path. The two resolver operations hold it in the slot coro_op already documents for it rather than in a member of their own: a keepalive nobody remembers to declare is how this was missed, and an inherited slot is one a reviewer can look for. Moving them onto that base also puts their work accounting on the executor the initiator counted, at both ends and on the drain path, which is what the file operations were already doing. The regression tests park a real operation behind a blocked pool worker that a test service releases at the top of teardown, and own the task they start so that the frame the library abandons is not mistaken for the leak under test. The blocker is the first item posted, so it is the one the worker it starts picks up, and the operation behind it stays queued. --- doc/error-handling-rulebook.md | 20 +- .../ROOT/pages/4.guide/4c2.configuration.adoc | 8 +- include/boost/corosio/detail/thread_pool.hpp | 215 +++++++++++++++--- include/boost/corosio/io_context.hpp | 22 +- .../detail/iocp/win_resolver_service.hpp | 51 +++-- .../posix_random_access_file_service.hpp | 57 +++-- .../native/detail/posix/posix_resolver.hpp | 55 +---- .../detail/posix/posix_resolver_service.hpp | 130 +++++------ .../posix/posix_stream_file_service.hpp | 61 +++-- src/corosio/src/io_context.cpp | 41 +++- test/unit/fault/iocp_faults.cpp | 4 +- test/unit/pool_teardown.hpp | 121 ++++++++++ test/unit/random_access_file.cpp | 53 +++++ test/unit/resolver.cpp | 82 +++++++ test/unit/stream_file.cpp | 53 +++++ test/unit/thread_pool.cpp | 118 +++++++++- 16 files changed, 862 insertions(+), 229 deletions(-) create mode 100644 test/unit/pool_teardown.hpp diff --git a/doc/error-handling-rulebook.md b/doc/error-handling-rulebook.md index 1230689d5..f968e2b5b 100644 --- a/doc/error-handling-rulebook.md +++ b/doc/error-handling-rulebook.md @@ -74,7 +74,10 @@ construction, so a system that refuses any of it throws from the constructor instead of from the first operation, and the failed construction leaves nothing open. An initiator may then assume that infrastructure exists, which is what makes "initiators never throw" -reachable at all. +reachable at all. The one piece held back is the thread pool's +workers, which start on the first blocking operation: a thread the +system refuses there is reported through that operation's own channel, +never thrown. ## 3. The Classification Test @@ -147,6 +150,15 @@ second channel: `work_finished()` on everything it dispatches needs a matching `work_started()`. An operation nothing counted reports through its owner's channel instead of the completion queue. +- Infrastructure an operation needs but does not own — the + blocking-I/O pool's worker threads, the wait reactor's polling + thread — is created on the first operation that needs it, and a + system that refuses it answers through that operation's completion. + The refusal is that operation's alone: the next one asks again. A + refusal the initiator learns of on its own thread, before any part of + the operation is cross-thread, completes there — the same exit the + closed-object and zero-length contracts take a few lines above it, + not a completion posted back through the scheduler. ## 6. Attributes and Spelling @@ -215,7 +227,11 @@ second channel: - A background thread that dies mid-flight owes one answer, not two: the error that killed it, latched, both to the operations it was holding and to the ones that arrive afterwards. `canceled` is a - stop token and belongs only to operations something cancelled. + stop token and belongs only to operations something cancelled — a + thread that never started is the same rule from the other end: the + operation that asked for it reports the code the system gave + (`resource_unavailable_try_again` where the refusal carries no code + of its own), never `canceled`. ## 8. Testing diff --git a/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc b/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc index a1df49366..aacc4d191 100644 --- a/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc +++ b/doc/modules/ROOT/pages/4.guide/4c2.configuration.adoc @@ -134,8 +134,12 @@ On POSIX platforms, file I/O (`stream_file`, `random_access_file`) and DNS resolution use a shared thread pool. * *Concurrent file operations*: increase to match expected - parallelism (e.g. 4 for four concurrent file reads). -* *No file I/O*: leave at 1 (the pool is created lazily). + parallelism (e.g. 4 for four concurrent file reads). The whole set + starts at once, on the first file or resolver call, so a larger pool + makes that one call more expensive and no other. +* *No file I/O*: leave at 1; the pool is created with the context, but + its workers start on the first file or resolver operation, so a pool + nothing uses costs nothing. [#single-threaded-mode] === Locking Tiers (`locking`) diff --git a/include/boost/corosio/detail/thread_pool.hpp b/include/boost/corosio/detail/thread_pool.hpp index d7db11749..469af5d7c 100644 --- a/include/boost/corosio/detail/thread_pool.hpp +++ b/include/boost/corosio/detail/thread_pool.hpp @@ -12,13 +12,16 @@ #include #include +#include #include #include +#include #include #include #include #include +#include #include #include @@ -45,7 +48,7 @@ namespace boost::corosio::detail { my_work w; w.func_ = &my_work::execute; w.result = &r; - pool.post( &w ); + auto ec = pool.post( &w ); @endcode */ struct pool_work_item : intrusive_queue::node @@ -64,8 +67,10 @@ struct pool_work_item : intrusive_queue::node calls). Registered as an `execution_context::service` so it is a singleton per io_context. - Threads are created eagerly in the constructor. The default - thread count is 1. + The service is created with its context, but the workers start on + the first `post()`: a context that never opens a file and never + resolves a name never pays for a thread. The default thread count + is 1. @par Thread Safety All public member functions are thread-safe. @@ -74,28 +79,42 @@ struct pool_work_item : intrusive_queue::node Sets a shutdown flag, notifies all threads, and joins them. In-flight blocking calls complete naturally before the thread exits. + + @note Create this service after the scheduler its work items post + completions to. Services shut down newest first, so a pool created + earlier joins its workers only after the scheduler has drained its + completion queue, and the completion the last worker posts is then + neither run nor destroyed. + + @note The type is symbol-visible because services are keyed by type + identity: with RTTI, hidden behind a shared library boundary, a + module that asks for the pool would look up, and create, one of its + own (the no-RTTI key is a template static whose visibility follows + the template it is instantiated from). */ -class thread_pool final : public capy::execution_context::service +class BOOST_COROSIO_SYMBOL_VISIBLE thread_pool final + : public capy::execution_context::service { std::mutex mutex_; std::condition_variable cv_; intrusive_queue work_queue_; std::vector threads_; + unsigned num_threads_; bool shutdown_ = false; void worker_loop(unsigned index); + std::error_code start_workers() noexcept; public: using key_type = thread_pool; /** Construct the thread pool service. - Eagerly creates all worker threads. + Records the worker count. The workers themselves start on the + first `post()`. @par Exception Safety - Strong guarantee. If thread creation fails, all - already-created threads are shut down and joined - before the exception propagates. + Strong guarantee. @param ctx Reference to the owning execution_context. @param num_threads Number of worker threads. Must be @@ -106,38 +125,64 @@ class thread_pool final : public capy::execution_context::service explicit thread_pool( [[maybe_unused]] capy::execution_context& ctx, unsigned num_threads = 1) + : num_threads_(num_threads) { if (!num_threads) throw std::logic_error("thread_pool requires at least 1 thread"); - threads_.reserve(num_threads); - try - { - for (unsigned i = 0; i < num_threads; ++i) - threads_.emplace_back([this, i] { worker_loop(i + 1); }); - } - catch (...) - { - shutdown(); - throw; - } } - ~thread_pool() override = default; + /** Destroy the pool, joining any worker `shutdown()` never reached. + + The context's shutdown walk is the normal path; this only + catches a pool created after that walk, whose `shutdown()` is + therefore never called and whose joinable threads would + otherwise terminate the process. A pool that was never posted + to holds no thread and needs neither. + */ + ~thread_pool() override + { + if (!threads_.empty()) + shutdown(); + } thread_pool(thread_pool const&) = delete; thread_pool& operator=(thread_pool const&) = delete; /** Enqueue a work item for execution on the thread pool. - Zero-allocation: the caller owns the work item's storage. + The first item posted starts the workers. Zero-allocation: + the caller owns the work item's storage. + + A refusal answers with the code the caller reports for the + operation it was starting, so that a system that will not give + the pool a thread is not mistaken for a cancellation. + + @par Thread Safety + Safe. Racing first posts start the workers once. @param w The work item to execute. Must remain valid until its `func_` has been called. - @return `true` if the item was enqueued, `false` if the - pool has already shut down. + @return An empty code if the item was enqueued; + `capy::error::canceled` if the pool has already shut + down; otherwise the code of the thread the system + refused, which left the pool with no worker at all. */ - bool post(pool_work_item* w) noexcept; + [[nodiscard]] std::error_code post(pool_work_item* w) noexcept; + + /** Return the number of workers the pool has started. + + Zero until the first `post()`, and zero again once + `shutdown()` has joined them. + + @par Thread Safety + Safe. + */ + unsigned worker_count() noexcept + { + std::lock_guard lock(mutex_); + return static_cast(threads_.size()); + } /** Shut down the thread pool. @@ -176,17 +221,58 @@ thread_pool::worker_loop(unsigned index) } } -inline bool +// Called with mutex_ held, so the workers are started once however +// many threads race the first post. +inline std::error_code +thread_pool::start_workers() noexcept +{ + if (!threads_.empty()) + return {}; + std::error_code ec; + try + { + threads_.reserve(num_threads_); + for (unsigned i = 0; i < num_threads_; ++i) + threads_.emplace_back([this, i] { worker_loop(i + 1); }); + } + catch (std::system_error const& e) + { + // The refusal is carried out, not swallowed: a thread the + // system will not give is a real error and the operation that + // asked for it says so, rather than reporting the cancellation + // that belongs to a stop token. + ec = e.code(); + } + catch (...) + { + ec = std::make_error_code(std::errc::resource_unavailable_try_again); + } + // A pool short of workers still runs everything posted to it, only + // less of it at once, so a partial start is a start. What it does + // not do is come back for the rest: the size is a tuning knob, and + // topping it up would put a thread creation on the initiator's + // path for every operation after a refusal. + if (!threads_.empty()) + return {}; + return ec; +} + +inline std::error_code thread_pool::post(pool_work_item* w) noexcept { { std::lock_guard lock(mutex_); if (shutdown_) - return false; + return capy::error::canceled; + // The system can refuse a thread, and an initiator has no way + // to throw; a refused post is the failure the callers already + // report through the operation they were starting. + if (auto ec = start_workers()) + return ec; work_queue_.push(w); } cv_.notify_one(); - return true; + return {}; } inline void @@ -198,6 +284,10 @@ thread_pool::shutdown() } cv_.notify_all(); + // Unlocked, though a post may add to threads_: the flag above is + // published under the same mutex, so a post that has not taken it + // yet will find it set and start nothing, and one already inside + // released the mutex before this thread acquired it. for (auto& t : threads_) { if (t.joinable()) @@ -212,6 +302,77 @@ thread_pool::shutdown() } } +/** A reference to the context's shared thread pool, bound on first use. + + Services that hand blocking work to the pool hold one of these + instead of a reference bound at construction. They are constructed + from the scheduler's constructor, where the pool they created would + be older than the scheduler and would join too late; binding on + first use puts the pool after it instead. + + The owning `io_context` creates the pool service during + construction, so by the time any operation can run the binding only + ever finds it. That is what keeps `get()` from constructing + anything on an initiator's thread, and so from throwing where an + initiator may not: the throwing spelling exists for a scheduler + driven without an `io_context`. What the service defers is its + workers, and those are started by `post()`, which reports a refusal + rather than throwing it. + + @par Thread Safety + Distinct objects: Safe. + Shared objects: Safe. + + @see thread_pool +*/ +class thread_pool_ref +{ + capy::execution_context& ctx_; + std::atomic pool_{nullptr}; + +public: + /** Construct a reference into the given context. + + @param ctx The context whose pool is used. + */ + explicit thread_pool_ref(capy::execution_context& ctx) noexcept + : ctx_(ctx) + { + } + + thread_pool_ref(thread_pool_ref const&) = delete; + thread_pool_ref& operator=(thread_pool_ref const&) = delete; + + /** Return the pool, creating it if this is the first use. + + @par Preconditions + For the throwing clauses below to be unreachable, the owning + context must already hold the pool service. Every `io_context` + constructor installs it — what waits for a first post is the + service's workers, not the service — so the creating branch is + reached only by a scheduler driven without one. + + @par Exception Safety + Strong guarantee. + + @throws std::bad_alloc If the service cannot be allocated. + + @throws std::logic_error If the pool is asked for zero threads. + + @return The context's shared thread pool. + */ + thread_pool& get() + { + auto* p = pool_.load(std::memory_order_acquire); + if (!p) + { + p = &ctx_.use_service(); + pool_.store(p, std::memory_order_release); + } + return *p; + } +}; + } // namespace boost::corosio::detail #endif // BOOST_COROSIO_DETAIL_THREAD_POOL_HPP diff --git a/include/boost/corosio/io_context.hpp b/include/boost/corosio/io_context.hpp index cbae8d45f..9125f7db8 100644 --- a/include/boost/corosio/io_context.hpp +++ b/include/boost/corosio/io_context.hpp @@ -241,22 +241,24 @@ effective_concurrency_hint( */ class BOOST_COROSIO_DECL io_context : public capy::execution_context { - /// Pre-create services that depend on options (before construct). + /// Reject invalid options before the backend is constructed. void apply_options_pre_(io_context_options const& opts); - /** Apply runtime tuning to the scheduler and finish bringing the - backend up. The tail of every options constructor: the backend - infrastructure whose setup reads these options is created here, - so a failure to create it throws from the constructor. */ + /** Create the blocking-I/O thread pool, apply runtime tuning to the + scheduler and finish bringing the backend up. The tail of every + options constructor: the backend infrastructure whose setup reads + these options is created here, so a failure to create it throws + from the constructor. */ void apply_options_post_( io_context_options const& opts, unsigned concurrency_hint); - /** Apply only the decomposed threading configuration (locking tiers), - then finish bringing the backend up. The tail of every plain - constructor, which — unlike the options constructors — - deliberately leaves the reactor budget at its defaults rather than - engaging the multi-thread post-everything heuristic. */ + /** Create the blocking-I/O thread pool and apply only the decomposed + threading configuration (locking tiers), then finish bringing the + backend up. The tail of every plain constructor, which — unlike + the options constructors — deliberately leaves the reactor budget + at its defaults rather than engaging the multi-thread + post-everything heuristic. */ void apply_threading_(io_context_options const& opts); protected: diff --git a/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp b/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp index dfda81821..6d01f3966 100644 --- a/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp @@ -85,15 +85,27 @@ class BOOST_COROSIO_DECL win_resolver_service final /** Notify scheduler that I/O work completed. */ void work_finished() noexcept; - /** Return the resolver thread pool. */ - thread_pool& pool() noexcept + /** Return the resolver thread pool. + + The pool's service is created on first use, so this can fail + where a plain accessor could not. Its workers start later, on + the first post, and a thread the system refuses there is + reported by that post rather than thrown here. + + @throws std::bad_alloc If the service cannot be allocated. + + @return The context's shared blocking-I/O pool. + + @see thread_pool_ref::get + */ + thread_pool& pool() { - return pool_; + return pool_.get(); } private: scheduler& sched_; - thread_pool& pool_; + thread_pool_ref pool_; win_mutex mutex_; intrusive_list resolver_list_; std::unordered_map> @@ -298,6 +310,9 @@ reverse_resolve_op::do_complete( if (!owner) { op->stop_cb.reset(); + // Dropping the keepalive may destroy the implementation this + // op is embedded in, so nothing may touch it afterwards. + auto suicide = std::move(op->impl_ptr); return; } @@ -321,6 +336,9 @@ reverse_resolve_op::do_complete( } op->cont.h = op->h; + // Hold the keepalive across the dispatch: it may be the last + // reference to the implementation this op is embedded in. + auto prevent_destroy = std::move(op->impl_ptr); dispatch_coro(op->ex, op->cont).resume(); } @@ -418,15 +436,21 @@ win_resolver::reverse_resolve( reverse_pool_op_.resolver_ = this; reverse_pool_op_.ref_ = this->shared_from_this(); reverse_pool_op_.func_ = &win_resolver::do_reverse_resolve_work; - if (!svc_.pool().post(&reverse_pool_op_)) + if (auto pec = svc_.pool().post(&reverse_pool_op_)) { - // Pool shut down — complete with cancellation + // The pool is shutting down, or the system refused it a thread. + // Nothing of this resolve went cross-thread, so it answers here + // rather than through a completion the scheduler has to carry + // back. reverse_pool_op_.ref_.reset(); - op.cancelled.store(true, std::memory_order_release); + op.stop_cb.reset(); svc_.work_finished(); - svc_.post(&reverse_op_); + *ec = pec; + op.cont.h = h; + return dispatch_coro(d, op.cont); } - // completion is always posted to scheduler queue, never inline. + // The work the pool took completes on its own thread and is always + // posted to the scheduler queue, never inline. return std::noop_coroutine(); } @@ -489,9 +513,10 @@ win_resolver::do_reverse_resolve_work(pool_work_item* w) noexcept self->svc_.work_finished(); - // Move ref to stack before post — post may trigger destroy_impl - // which erases the last shared_ptr, destroying *self (and *pw) - auto ref = std::move(pw->ref_); + // Hand the keepalive to the op: the completion waits in the + // scheduler's queue, and the implementation embedding it must + // outlive that wait. Nothing may touch *self after the post. + self->reverse_op_.impl_ptr = std::move(pw->ref_); self->svc_.post(&self->reverse_op_); } @@ -500,7 +525,7 @@ win_resolver::do_reverse_resolve_work(pool_work_item* w) noexcept inline win_resolver_service::win_resolver_service( capy::execution_context& ctx, scheduler& sched) : sched_(sched) - , pool_(ctx.use_service()) + , pool_(ctx) { } diff --git a/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp b/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp index ea048a762..0a4c4cdfe 100644 --- a/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp @@ -33,7 +33,7 @@ class BOOST_COROSIO_DECL posix_random_access_file_service final posix_random_access_file_service( capy::execution_context& ctx, scheduler& sched) : sched_(&sched) - , pool_(get_or_create_pool(ctx)) + , pool_(ctx) { } @@ -123,22 +123,27 @@ class BOOST_COROSIO_DECL posix_random_access_file_service final sched_->work_finished(); } - thread_pool& pool() noexcept - { - return pool_; - } + /** Return the thread pool that runs this service's file work. -private: - static thread_pool& get_or_create_pool(capy::execution_context& ctx) + The pool's service is created on first use, so this can fail + where a plain accessor could not. Its workers start later, on + the first post, and a thread the system refuses there is + reported by that post rather than thrown here. + + @throws std::bad_alloc If the service cannot be allocated. + + @return The context's shared blocking-I/O pool. + + @see thread_pool_ref::get + */ + thread_pool& pool() { - auto* p = ctx.find_service(); - if (p) - return *p; - return ctx.make_service(); + return pool_.get(); } +private: scheduler* sched_; - thread_pool& pool_; + thread_pool_ref pool_; std::mutex mutex_; intrusive_list file_list_; std::unordered_map< @@ -213,10 +218,18 @@ posix_random_access_file::read_some_at( } static_cast(op)->func_ = &raf_op::do_work; - if (!svc_.pool().post(static_cast(op))) + if (auto pec = svc_.pool().post(static_cast(op))) { - op->cancelled.store(true, std::memory_order_release); - svc_.post(static_cast(op)); + // The pool is shutting down, or the system refused it a thread. + // Nothing of this read went cross-thread, so it answers here + // like the closed-descriptor and zero-length exits above rather + // than through a completion the scheduler has to carry back. + // destroy() is the discard the op never reaching the queue + // needs: it unlinks, unwinds the work count and frees. + op->destroy(); + *ec = pec; + *bytes_out = 0; + return h; } return std::noop_coroutine(); } @@ -276,10 +289,18 @@ posix_random_access_file::write_some_at( } static_cast(op)->func_ = &raf_op::do_work; - if (!svc_.pool().post(static_cast(op))) + if (auto pec = svc_.pool().post(static_cast(op))) { - op->cancelled.store(true, std::memory_order_release); - svc_.post(static_cast(op)); + // The pool is shutting down, or the system refused it a thread. + // Nothing of this write went cross-thread, so it answers here + // like the closed-descriptor and zero-length exits above rather + // than through a completion the scheduler has to carry back. + // destroy() is the discard the op never reaching the queue + // needs: it unlinks, unwinds the work count and frees. + op->destroy(); + *ec = pec; + *bytes_out = 0; + return h; } return std::noop_coroutine(); } diff --git a/include/boost/corosio/native/detail/posix/posix_resolver.hpp b/include/boost/corosio/native/detail/posix/posix_resolver.hpp index 6edd438dc..14da52802 100644 --- a/include/boost/corosio/native/detail/posix/posix_resolver.hpp +++ b/include/boost/corosio/native/detail/posix/posix_resolver.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -163,26 +164,10 @@ class posix_resolver final public: // resolve_op - operation state for a single DNS resolution - struct resolve_op : scheduler_op + struct resolve_op : coro_op { - struct canceller - { - resolve_op* op; - void operator()() const noexcept - { - op->request_cancel(); - } - }; - - // Coroutine state - std::coroutine_handle<> h; - capy::continuation cont; - capy::executor_ref ex; - posix_resolver* impl = nullptr; - - // Output parameters - std::error_code* ec_out = nullptr; - resolver_results* out = nullptr; + /// Where the endpoints are handed back. + resolver_results* out = nullptr; // Input parameters (owned copies for thread safety) std::string host; @@ -193,40 +178,18 @@ class posix_resolver final resolver_results stored_results; int gai_error = 0; - // Thread coordination - std::atomic cancelled{false}; - std::optional> stop_cb; - resolve_op() = default; void reset() noexcept; void operator()() override; void destroy() override; - void request_cancel() noexcept; - void start(std::stop_token const& token); }; // reverse_resolve_op - operation state for reverse DNS resolution - struct reverse_resolve_op : scheduler_op + struct reverse_resolve_op : coro_op { - struct canceller - { - reverse_resolve_op* op; - void operator()() const noexcept - { - op->request_cancel(); - } - }; - - // Coroutine state - std::coroutine_handle<> h; - capy::continuation cont; - capy::executor_ref ex; - posix_resolver* impl = nullptr; - - // Output parameters - std::error_code* ec_out = nullptr; + /// Where the name is handed back. reverse_resolver_result* result_out = nullptr; // Input parameters @@ -238,17 +201,11 @@ class posix_resolver final std::string stored_service; int gai_error = 0; - // Thread coordination - std::atomic cancelled{false}; - std::optional> stop_cb; - reverse_resolve_op() = default; void reset() noexcept; void operator()() override; void destroy() override; - void request_cancel() noexcept; - void start(std::stop_token const& token); }; /// Embedded pool work item for thread pool dispatch. diff --git a/include/boost/corosio/native/detail/posix/posix_resolver_service.hpp b/include/boost/corosio/native/detail/posix/posix_resolver_service.hpp index ff3e4d268..faf735c3e 100644 --- a/include/boost/corosio/native/detail/posix/posix_resolver_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_resolver_service.hpp @@ -37,7 +37,7 @@ class BOOST_COROSIO_DECL posix_resolver_service final posix_resolver_service(capy::execution_context& ctx, scheduler& sched) : sched_(&sched) - , pool_(ctx.use_service()) + , pool_(ctx) { } @@ -59,13 +59,23 @@ class BOOST_COROSIO_DECL posix_resolver_service final void destroy_impl(posix_resolver& impl); void post(scheduler_op* op); - void work_started() noexcept; - void work_finished() noexcept; - /** Return the resolver thread pool. */ - thread_pool& pool() noexcept + /** Return the resolver thread pool. + + The pool's service is created on first use, so this can fail + where a plain accessor could not. Its workers start later, on + the first post, and a thread the system refuses there is + reported by that post rather than thrown here. + + @throws std::bad_alloc If the service cannot be allocated. + + @return The context's shared blocking-I/O pool. + + @see thread_pool_ref::get + */ + thread_pool& pool() { - return pool_; + return pool_.get(); } /// True when the resolver thread pool is unavailable: the `unsafe` tier, @@ -78,7 +88,7 @@ class BOOST_COROSIO_DECL posix_resolver_service final private: scheduler* sched_; - thread_pool& pool_; + thread_pool_ref pool_; std::mutex mutex_; intrusive_list resolver_list_; std::unordered_map> @@ -274,7 +284,10 @@ posix_resolver::resolve_op::operator()() if (out && !was_cancelled && gai_error == 0) *out = std::move(stored_results); - impl->svc_.work_finished(); + // Hold the keepalive across the dispatch: it may be the last + // reference to the implementation this op is embedded in. + auto prevent_destroy = std::move(impl_ptr); + ex.on_work_finished(); cont.h = h; dispatch_coro(ex, cont).resume(); } @@ -283,22 +296,10 @@ inline void posix_resolver::resolve_op::destroy() { stop_cb.reset(); -} - -inline void -posix_resolver::resolve_op::request_cancel() noexcept -{ - cancelled.store(true, std::memory_order_release); -} - -inline void -posix_resolver::resolve_op::start(std::stop_token const& token) -{ - cancelled.store(false, std::memory_order_release); - stop_cb.reset(); - - if (token.stop_possible()) - stop_cb.emplace(token, canceller{this}); + auto local_ex = ex; + // May destroy the implementation, and with it this op. + impl_ptr.reset(); + local_ex.on_work_finished(); } // posix_resolver::reverse_resolve_op implementation @@ -340,7 +341,10 @@ posix_resolver::reverse_resolve_op::operator()() ep, std::move(stored_host), std::move(stored_service)); } - impl->svc_.work_finished(); + // Hold the keepalive across the dispatch: it may be the last + // reference to the implementation this op is embedded in. + auto prevent_destroy = std::move(impl_ptr); + ex.on_work_finished(); cont.h = h; dispatch_coro(ex, cont).resume(); } @@ -349,22 +353,10 @@ inline void posix_resolver::reverse_resolve_op::destroy() { stop_cb.reset(); -} - -inline void -posix_resolver::reverse_resolve_op::request_cancel() noexcept -{ - cancelled.store(true, std::memory_order_release); -} - -inline void -posix_resolver::reverse_resolve_op::start(std::stop_token const& token) -{ - cancelled.store(false, std::memory_order_release); - stop_cb.reset(); - - if (token.stop_possible()) - stop_cb.emplace(token, canceller{this}); + auto local_ex = ex; + // May destroy the implementation, and with it this op. + impl_ptr.reset(); + local_ex.on_work_finished(); } // posix_resolver implementation @@ -391,7 +383,6 @@ posix_resolver::resolve( op.reset(); op.h = h; op.ex = ex; - op.impl = this; op.ec_out = ec; op.out = out; op.host = host; @@ -406,12 +397,18 @@ posix_resolver::resolve( resolve_pool_op_.resolver_ = this; resolve_pool_op_.ref_ = this->shared_from_this(); resolve_pool_op_.func_ = &posix_resolver::do_resolve_work; - if (!svc_.pool().post(&resolve_pool_op_)) + if (auto pec = svc_.pool().post(&resolve_pool_op_)) { - // Pool shut down — complete with cancellation + // The pool is shutting down, or the system refused it a thread. + // Nothing of this resolve went cross-thread, so it answers here + // like the no-resolver exit above rather than through a + // completion the scheduler has to carry back. resolve_pool_op_.ref_.reset(); - op.cancelled.store(true, std::memory_order_release); - svc_.post(&op_); + op.stop_cb.reset(); + op.ex.on_work_finished(); + *ec = pec; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } return std::noop_coroutine(); } @@ -437,7 +434,6 @@ posix_resolver::reverse_resolve( op.reset(); op.h = h; op.ex = ex; - op.impl = this; op.ec_out = ec; op.result_out = result_out; op.ep = ep; @@ -451,12 +447,18 @@ posix_resolver::reverse_resolve( reverse_pool_op_.resolver_ = this; reverse_pool_op_.ref_ = this->shared_from_this(); reverse_pool_op_.func_ = &posix_resolver::do_reverse_resolve_work; - if (!svc_.pool().post(&reverse_pool_op_)) + if (auto pec = svc_.pool().post(&reverse_pool_op_)) { - // Pool shut down — complete with cancellation + // The pool is shutting down, or the system refused it a thread. + // Nothing of this resolve went cross-thread, so it answers here + // like the no-resolver exit above rather than through a + // completion the scheduler has to carry back. reverse_pool_op_.ref_.reset(); - op.cancelled.store(true, std::memory_order_release); - svc_.post(&reverse_op_); + op.stop_cb.reset(); + op.ex.on_work_finished(); + *ec = pec; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } return std::noop_coroutine(); } @@ -502,9 +504,10 @@ posix_resolver::do_resolve_work(pool_work_item* w) noexcept if (ai) ::freeaddrinfo(ai); - // Move ref to stack before post — post may trigger destroy_impl - // which erases the last shared_ptr, destroying *self (and *pw) - auto ref = std::move(pw->ref_); + // Hand the keepalive to the op: the completion waits in the + // scheduler's queue, and the implementation embedding it must + // outlive that wait. Nothing may touch *self after the post. + self->op_.impl_ptr = std::move(pw->ref_); self->svc_.post(&self->op_); } @@ -552,9 +555,10 @@ posix_resolver::do_reverse_resolve_work(pool_work_item* w) noexcept } } - // Move ref to stack before post — post may trigger destroy_impl - // which erases the last shared_ptr, destroying *self (and *pw) - auto ref = std::move(pw->ref_); + // Hand the keepalive to the op: the completion waits in the + // scheduler's queue, and the implementation embedding it must + // outlive that wait. Nothing may touch *self after the post. + self->reverse_op_.impl_ptr = std::move(pw->ref_); self->svc_.post(&self->reverse_op_); } @@ -607,18 +611,6 @@ posix_resolver_service::post(scheduler_op* op) sched_->post(op); } -inline void -posix_resolver_service::work_started() noexcept -{ - sched_->work_started(); -} - -inline void -posix_resolver_service::work_finished() noexcept -{ - sched_->work_finished(); -} - // Free function to get/create the resolver service inline posix_resolver_service& diff --git a/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp b/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp index 39b6a4027..543216e87 100644 --- a/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp @@ -36,7 +36,7 @@ class BOOST_COROSIO_DECL posix_stream_file_service final posix_stream_file_service( capy::execution_context& ctx, scheduler& sched) : sched_(&sched) - , pool_(get_or_create_pool(ctx)) + , pool_(ctx) { } @@ -123,22 +123,27 @@ class BOOST_COROSIO_DECL posix_stream_file_service final sched_->work_finished(); } - thread_pool& pool() noexcept - { - return pool_; - } + /** Return the thread pool that runs this service's file work. -private: - static thread_pool& get_or_create_pool(capy::execution_context& ctx) + The pool's service is created on first use, so this can fail + where a plain accessor could not. Its workers start later, on + the first post, and a thread the system refuses there is + reported by that post rather than thrown here. + + @throws std::bad_alloc If the service cannot be allocated. + + @return The context's shared blocking-I/O pool. + + @see thread_pool_ref::get + */ + thread_pool& pool() { - auto* p = ctx.find_service(); - if (p) - return *p; - return ctx.make_service(); + return pool_.get(); } +private: scheduler* sched_; - thread_pool& pool_; + thread_pool_ref pool_; std::mutex mutex_; intrusive_list file_list_; std::unordered_map> @@ -206,11 +211,19 @@ posix_stream_file::read_some( read_pool_op_.file_ = this; read_pool_op_.ref_ = this->shared_from_this(); read_pool_op_.func_ = &posix_stream_file::do_read_work; - if (!svc_.pool().post(&read_pool_op_)) + if (auto pec = svc_.pool().post(&read_pool_op_)) { - op.impl_ref = std::move(read_pool_op_.ref_); - op.cancelled.store(true, std::memory_order_release); - svc_.post(&read_op_); + // The pool is shutting down, or the system refused it a thread. + // Nothing of this read went cross-thread, so it answers here + // like the closed-descriptor and zero-length exits above rather + // than through a completion the scheduler has to carry back. + read_pool_op_.ref_.reset(); + op.stop_cb.reset(); + op.ex.on_work_finished(); + *ec = pec; + *bytes_out = 0; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } return std::noop_coroutine(); } @@ -299,11 +312,19 @@ posix_stream_file::write_some( write_pool_op_.file_ = this; write_pool_op_.ref_ = this->shared_from_this(); write_pool_op_.func_ = &posix_stream_file::do_write_work; - if (!svc_.pool().post(&write_pool_op_)) + if (auto pec = svc_.pool().post(&write_pool_op_)) { - op.impl_ref = std::move(write_pool_op_.ref_); - op.cancelled.store(true, std::memory_order_release); - svc_.post(&write_op_); + // The pool is shutting down, or the system refused it a thread. + // Nothing of this write went cross-thread, so it answers here + // like the closed-descriptor and zero-length exits above rather + // than through a completion the scheduler has to carry back. + write_pool_op_.ref_.reset(); + op.stop_cb.reset(); + op.ex.on_work_finished(); + *ec = pec; + *bytes_out = 0; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } return std::noop_coroutine(); } diff --git a/src/corosio/src/io_context.cpp b/src/corosio/src/io_context.cpp index 207ee195f..200635c05 100644 --- a/src/corosio/src/io_context.cpp +++ b/src/corosio/src/io_context.cpp @@ -148,24 +148,41 @@ io_uring_t::construct(capy::execution_context& ctx, unsigned concurrency_hint) namespace { -// Pre-create services that must exist before construct() runs. +// Reject options that construct() would otherwise act on. void -pre_create_services( - [[maybe_unused]] capy::execution_context& ctx, - [[maybe_unused]] io_context_options const& opts) +check_options([[maybe_unused]] io_context_options const& opts) { #if BOOST_COROSIO_POSIX if (opts.thread_pool_size < 1) throw std::invalid_argument( "thread_pool_size must be at least 1"); - // Pre-create the shared thread pool with the configured size. - // This must happen before construct() because the scheduler - // constructor creates file and resolver services that call - // get_or_create_pool(), which would create a 1-thread pool. - if (opts.thread_pool_size != 1) - ctx.make_service(opts.thread_pool_size); #endif +} +// Create the shared pool that runs blocking file and DNS work. Runs +// after construct() so the pool is newer than the scheduler its work +// items post completions to: services shut down newest first, and the +// pool must join its workers while that scheduler can still drain what +// the last of them posted. Only the service is built here; its workers +// wait for a first post, so a context that hands off no blocking work +// carries no thread for the pool it holds. +// +// Every io_context constructor has to reach here, and reach it before +// anything can call thread_pool_ref::get(): that is what keeps the +// binding from ever constructing a pool on an initiator's thread, and +// make_service throws on a duplicate if get() got there first. +void +create_thread_pool( + capy::execution_context& ctx, + [[maybe_unused]] io_context_options const& opts) +{ +#if BOOST_COROSIO_POSIX + ctx.make_service(opts.thread_pool_size); +#else + // thread_pool_size is a POSIX file-service option; the IOCP + // backend uses the pool for DNS alone. + ctx.make_service(); +#endif } // Map the locking tier to the scheduler's threading facilities. one_thread is @@ -300,7 +317,7 @@ io_context::io_context( void io_context::apply_options_pre_(io_context_options const& opts) { - pre_create_services(*this, opts); + check_options(opts); } void @@ -308,6 +325,7 @@ io_context::apply_options_post_( io_context_options const& opts_in, unsigned concurrency_hint) { + create_thread_pool(*this, opts_in); apply_scheduler_options(*sched_, opts_in, concurrency_hint); finish_construction(*sched_); } @@ -315,6 +333,7 @@ io_context::apply_options_post_( void io_context::apply_threading_(io_context_options const& opts_in) { + create_thread_pool(*this, opts_in); sched_->configure_threading(make_threading_config(opts_in)); finish_construction(*sched_); } diff --git a/test/unit/fault/iocp_faults.cpp b/test/unit/fault/iocp_faults.cpp index 2520bf48e..d87a10a7f 100644 --- a/test/unit/fault/iocp_faults.cpp +++ b/test/unit/fault/iocp_faults.cpp @@ -230,7 +230,9 @@ struct iocp_faults // the thread that holds it, and an arm whose nth is out of // reach never fires, so `shield` turns every wait on this // thread into a plain forward and leaves the process-wide arm - // for the only other thread in the process. + // for the timer thread, the only other one this context + // starts: the blocking-I/O pool holds no worker until + // something posts blocking work, and this test posts none. fault_scope shield(sys::WaitForSingleObject, ERROR_INVALID_HANDLE, (std::numeric_limits::max)()); fault_scope f(sys::WaitForSingleObject, ERROR_INVALID_HANDLE, 1, diff --git a/test/unit/pool_teardown.hpp b/test/unit/pool_teardown.hpp new file mode 100644 index 000000000..188a286ad --- /dev/null +++ b/test/unit/pool_teardown.hpp @@ -0,0 +1,121 @@ +// +// 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_TEST_POOL_TEARDOWN_HPP +#define BOOST_COROSIO_TEST_POOL_TEARDOWN_HPP + +#include +#include +#include + +#include +#include + +namespace boost::corosio::test { + +/** A pool work item that occupies a worker until it is released. + + Post one of these to a context's thread pool and every item posted + after it stays queued behind it. That is what holds a real + operation's pool work undone across the context's destruction, + without asking the test to time anything. + + Declare the blocker before the context: the pool joins its workers + while the context is being destroyed, so the item has to outlive it. + + @par Thread Safety + Distinct objects: Safe. + Shared objects: Safe. +*/ +class pool_blocker : public detail::pool_work_item +{ + std::mutex mutex_; + std::condition_variable cv_; + bool released_ = false; + + static void run(detail::pool_work_item* w) noexcept + { + auto* self = static_cast(w); + std::unique_lock lock(self->mutex_); + self->cv_.wait(lock, [self] { return self->released_; }); + } + +public: + /// Construct an item that blocks the worker running it. + pool_blocker() noexcept + { + func_ = &pool_blocker::run; + } + + /// Let the occupied worker go. + void release() + { + { + std::lock_guard lock(mutex_); + released_ = true; + } + cv_.notify_one(); + } +}; + +/** A service that releases a @ref pool_blocker as the context shuts down. + + Create it last so the newest-first shutdown walk runs it before any + library service: the blocked worker is let go at the very top of + teardown, and the operation queued behind it therefore completes + while the context is being destroyed rather than before. +*/ +class pool_release_gate final + : public capy::execution_context::service +{ + pool_blocker* blocker_ = nullptr; + + void shutdown() override + { + if (blocker_) + blocker_->release(); + } + +public: + /// The lookup key for this service. + using key_type = pool_release_gate; + + /// Construct an unarmed gate. + explicit pool_release_gate(capy::execution_context&) noexcept {} + + /** Arm the gate. + + @param b The blocker to release when the context shuts down. + */ + void arm(pool_blocker& b) noexcept + { + blocker_ = &b; + } +}; + +/** Occupy the context's thread pool with a blocker armed for teardown. + + @param ioc The context whose pool is occupied. + @param blocker The item to post; must outlive `ioc`. + + @return True if the pool accepted the item. +*/ +inline bool +park_pool_worker(io_context& ioc, pool_blocker& blocker) +{ + auto* pool = ioc.find_service(); + if (!pool) + return false; + ioc.use_service().arm(blocker); + return !pool->post(&blocker); +} + +} // namespace boost::corosio::test + +#endif // BOOST_COROSIO_TEST_POOL_TEARDOWN_HPP diff --git a/test/unit/random_access_file.cpp b/test/unit/random_access_file.cpp index ab87be6f8..9f572d077 100644 --- a/test/unit/random_access_file.cpp +++ b/test/unit/random_access_file.cpp @@ -21,16 +21,20 @@ #include #include #include +#include #include #include #include "context.hpp" +#include "pool_teardown.hpp" #include "test_suite.hpp" +#include #include #include #include #include +#include #include #include #include @@ -669,6 +673,50 @@ struct random_access_file_test BOOST_TEST(!resumed); } + // A read queued behind a worker that is released only once teardown + // has begun. The pool has to join before the scheduler drains, or + // the completion the worker posts on its way out is neither run nor + // destroyed and the operation's keepalive leaks. + // + // The task is started by hand and owned by the test, so the frame + // the library abandons at teardown is destroyed here rather than + // leaked: what LeakSanitizer sees left over is the defect alone. + void testDestroyWithPoolWorkQueued() + { +#if BOOST_COROSIO_HAS_IO_URING + // io_uring reads through the ring, never through the pool. + if constexpr (std::is_same_v< + std::remove_const_t, io_uring_t>) + return; +#endif + temp_file tmp("raf_pool_teardown_", "hello world"); + auto const path = tmp.path; + bool resumed = false; + test::pool_blocker blocker; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc(Backend); + BOOST_TEST(test::park_pool_worker(ioc, blocker)); + + random_access_file f(ioc); + std::ignore = f.open(path, file_base::read_only); + auto reader = [&]() -> capy::task<> { + char buf[16]; + std::ignore = co_await f.read_some_at( + 0, capy::mutable_buffer(buf, sizeof(buf))); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(reader()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } + void run() { testConstruction(); @@ -721,6 +769,11 @@ struct random_access_file_test testCancelInflightOperation(); testCancelWithStoppedToken(); +#if BOOST_COROSIO_POSIX + // POSIX file work runs on the pool; IOCP uses overlapped I/O. + testDestroyWithPoolWorkQueued(); +#endif + #if !COROSIO_TEST_HAS_ASAN // Abandon parked coroutine frames by design; see context.hpp. testDestroyWithLiveFile(); diff --git a/test/unit/resolver.cpp b/test/unit/resolver.cpp index 5a05bf9c8..c7cfaeed2 100644 --- a/test/unit/resolver.cpp +++ b/test/unit/resolver.cpp @@ -20,13 +20,17 @@ #include #include #include +#include #include #include +#include +#include #include #include #include "context.hpp" +#include "pool_teardown.hpp" #include "test_suite.hpp" namespace boost::corosio { @@ -1107,6 +1111,79 @@ struct resolver_test BOOST_TEST(!resumed); } +#if BOOST_COROSIO_POSIX + // A resolve queued behind a worker that is released only once + // teardown has begun. The pool has to join before the scheduler + // drains, or the completion the worker posts on its way out is + // neither run nor destroyed and the operation's keepalive leaks. + // + // The task is started by hand and owned by the test, so the frame + // the library abandons at teardown is destroyed here rather than + // leaked: what LeakSanitizer sees left over is the defect alone. + // + // Forward resolution reaches the pool on POSIX only; IOCP resolves + // through GetAddrInfoExW and the completion port. + void testDestroyWithPoolResolveQueued() + { + bool resumed = false; + test::pool_blocker blocker; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc; + BOOST_TEST(test::park_pool_worker(ioc, blocker)); + + resolver r(ioc); + auto query = [&]() -> capy::task<> { + // Numeric, so the queued work needs no name service. + std::ignore = co_await r.resolve( + "127.0.0.1", "80", + resolve_flags::numeric_host + | resolve_flags::numeric_service); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(query()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } +#endif + + // The reverse half of the test above, which reaches the pool on + // every platform. + void testDestroyWithPoolReverseQueued() + { + bool resumed = false; + test::pool_blocker blocker; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc; + BOOST_TEST(test::park_pool_worker(ioc, blocker)); + + resolver r(ioc); + auto query = [&]() -> capy::task<> { + // Numeric, so the queued work needs no name service. + std::ignore = co_await r.resolve( + endpoint(ipv4_address::loopback(), 80), + reverse_flags::numeric_host + | reverse_flags::numeric_service); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(query()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } + void run() { // Construction and move semantics @@ -1172,6 +1249,11 @@ struct resolver_test testSequentialReverseResolves(); testMixedResolveAndReverseResolve(); +#if BOOST_COROSIO_POSIX + testDestroyWithPoolResolveQueued(); +#endif + testDestroyWithPoolReverseQueued(); + #if !COROSIO_TEST_HAS_ASAN // Abandon parked coroutine frames by design; see context.hpp. testDestroyWithLiveResolver(); diff --git a/test/unit/stream_file.cpp b/test/unit/stream_file.cpp index cd2436ee7..fbdbd5919 100644 --- a/test/unit/stream_file.cpp +++ b/test/unit/stream_file.cpp @@ -21,16 +21,20 @@ #include #include #include +#include #include #include #include "context.hpp" +#include "pool_teardown.hpp" #include "test_suite.hpp" +#include #include #include #include #include +#include #include #include #include @@ -971,6 +975,50 @@ struct stream_file_test BOOST_TEST(!resumed); } + // A read queued behind a worker that is released only once teardown + // has begun. The pool has to join before the scheduler drains, or + // the completion the worker posts on its way out is neither run nor + // destroyed and the operation's keepalive leaks. + // + // The task is started by hand and owned by the test, so the frame + // the library abandons at teardown is destroyed here rather than + // leaked: what LeakSanitizer sees left over is the defect alone. + void testDestroyWithPoolWorkQueued() + { +#if BOOST_COROSIO_HAS_IO_URING + // io_uring reads through the ring, never through the pool. + if constexpr (std::is_same_v< + std::remove_const_t, io_uring_t>) + return; +#endif + temp_file tmp("sf_pool_teardown_", "hello world"); + auto const path = tmp.path; + bool resumed = false; + test::pool_blocker blocker; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc(Backend); + BOOST_TEST(test::park_pool_worker(ioc, blocker)); + + stream_file f(ioc); + std::ignore = f.open(path, file_base::read_only); + auto reader = [&]() -> capy::task<> { + char buf[16]; + std::ignore = co_await f.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(reader()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } + void run() { testConstruction(); @@ -1020,6 +1068,11 @@ struct stream_file_test testSeekNegative(); testCancelWithStoppedToken(); +#if BOOST_COROSIO_POSIX + // POSIX file work runs on the pool; IOCP uses overlapped I/O. + testDestroyWithPoolWorkQueued(); +#endif + #if !COROSIO_TEST_HAS_ASAN // Abandon parked coroutine frames by design; see context.hpp. testDestroyWithLiveFile(); diff --git a/test/unit/thread_pool.cpp b/test/unit/thread_pool.cpp index 092819d84..aabe675b1 100644 --- a/test/unit/thread_pool.cpp +++ b/test/unit/thread_pool.cpp @@ -15,10 +15,52 @@ #include #include +#if defined(__linux__) +#include +#include +#include +#endif + #include "test_suite.hpp" namespace boost::corosio { +#if defined(__linux__) + +// Count this process's live pool workers by the name each one gives +// itself before it can run anything, so what is counted is the +// spawning and not the timing of it. +inline int +pool_thread_count() +{ + auto* d = ::opendir("/proc/self/task"); + if (!d) + return -1; + int n = 0; + while (auto* e = ::readdir(d)) + { + if (e->d_name[0] == '.') + continue; + // Sized for the longest name the directory entry can carry, + // so the path is never truncated. + char path[sizeof("/proc/self/task//comm") + sizeof(e->d_name)]; + std::snprintf( + path, sizeof(path), "/proc/self/task/%s/comm", e->d_name); + auto* f = std::fopen(path, "r"); + if (!f) + continue; + char name[32] = {}; + if (std::fgets(name, sizeof(name), f) && + std::strncmp(name, "tpool-svc-", 10) == 0) + ++n; + std::fclose(f); + } + ::closedir(d); + return n; +} + +#endif + struct test_work : detail::pool_work_item { std::atomic* counter = nullptr; @@ -45,7 +87,7 @@ struct thread_pool_test { items[i].counter = &counter; items[i].func_ = &test_work::execute; - BOOST_TEST(pool.post(&items[i])); + BOOST_TEST(!pool.post(&items[i])); } // Shutdown should drain all queued tasks @@ -73,7 +115,7 @@ struct thread_pool_test flag_work fw; fw.flag = &ran; fw.func_ = &flag_work::execute; - pool.post(&fw); + BOOST_TEST(!pool.post(&fw)); // Give it a moment to process while (!ran.load()) @@ -90,12 +132,14 @@ struct thread_pool_test pool.shutdown(); - // post() must return false after shutdown + // A shutdown pool answers a post with the cancellation its + // callers report, and starts no thread doing it. test_work tw; std::atomic counter{0}; tw.counter = &counter; tw.func_ = &test_work::execute; - BOOST_TEST(!pool.post(&tw)); + BOOST_TEST(pool.post(&tw) == capy::error::canceled); + BOOST_TEST(pool.worker_count() == 0); BOOST_TEST(counter.load() == 0); // Second shutdown must not hang @@ -123,13 +167,17 @@ struct thread_pool_test std::atomic* arrived; unsigned expected; std::atomic* done; + std::atomic* give_up; static void execute(detail::pool_work_item* p) noexcept { auto* self = static_cast(p); self->arrived->fetch_add(1); - // Spin until all threads have arrived - while (self->arrived->load() < self->expected) + // Spin until all threads have arrived, or until the + // test says the arrival it is waiting for is one the + // pool never started a worker for. + while (self->arrived->load() < self->expected && + !self->give_up->load()) std::this_thread::yield(); self->done->fetch_add(1); } @@ -137,14 +185,31 @@ struct thread_pool_test std::atomic arrived{0}; std::atomic done{0}; + std::atomic give_up{false}; barrier_work items[num_threads]; for (unsigned i = 0; i < num_threads; ++i) { items[i].arrived = &arrived; items[i].expected = num_threads; items[i].done = &done; + items[i].give_up = &give_up; items[i].func_ = &barrier_work::execute; - pool.post(&items[i]); + BOOST_TEST(!pool.post(&items[i])); + + // The first post starts the whole set. The barrier below + // waits for every one of them, so a short start has to + // fail the test rather than hang it. + if (i == 0) + { + bool const full = pool.worker_count() == num_threads; + BOOST_TEST(full); + if (!full) + { + give_up.store(true); + pool.shutdown(); + return; + } + } } pool.shutdown(); @@ -153,6 +218,44 @@ struct thread_pool_test BOOST_TEST(done.load() == static_cast(num_threads)); } + void testLazyWorkers() + { +#if defined(__linux__) + // A worker joined by an earlier suite can outlive its join in + // /proc for a moment. A count that starts at zero says the + // moment has passed, and from there only this context can add + // to it. + bool const countable = pool_thread_count() == 0; +#endif + io_context ioc; + auto* pool = ioc.find_service(); + + // The context creates the service, not its workers. + BOOST_TEST(pool != nullptr); + BOOST_TEST(pool->worker_count() == 0); +#if defined(__linux__) + // The counted skip keeps a dirty environment from passing as + // a proof: what stands down is the check, visibly. + BOOST_TEST(!countable || pool_thread_count() == 0); +#endif + + std::atomic counter{0}; + test_work w; + w.counter = &counter; + w.func_ = &test_work::execute; + BOOST_TEST(!pool->post(&w)); + BOOST_TEST(pool->worker_count() == 1); + while (counter.load() == 0) + std::this_thread::yield(); + +#if defined(__linux__) + // The worker that ran the item is still parked on the queue. + BOOST_TEST(!countable || pool_thread_count() == 1); +#endif + pool->shutdown(); + BOOST_TEST(pool->worker_count() == 0); + } + void run() { testDrainOnShutdown(); @@ -160,6 +263,7 @@ struct thread_pool_test testPostAfterShutdown(); testZeroThreads(); testMultipleThreads(); + testLazyWorkers(); } }; From 7863401a1521fd1001350cddcb9022226c104b68 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 28 Aug 2026 22:24:03 +0200 Subject: [PATCH 29/34] fix(signal_set): give the registration back when the service shuts down The signal service's shutdown walk deleted each registration node with a bare delete: it never decremented the process-global registration count, never restored the disposition when that count reached zero, and never reset the flags the first registration established. A signal_set held by an abandoned coroutine frame -- the only way one can still be registered when its io_context is destroyed -- therefore left the signal installed for the life of the process, and the next add() of it was refused as an incompatible-flags conflict. The walk now gives each registration's count and disposition back the way clear() does. It drops the per-service table wholesale instead of unlinking node by node: every live registration hung off an implementation the walk just deleted, so the whole table goes stale at once, and deliver_signal() walks this service until the destructor unlinks it. The Windows service had the same hole and gets the same treatment. --- doc/error-handling-rulebook.md | 11 +- .../native/detail/iocp/win_signals.hpp | 31 +++- .../detail/posix/posix_signal_service.hpp | 38 +++++ test/unit/fault/posix_faults.cpp | 15 +- test/unit/signal_set.cpp | 132 +++++++++++++++++- 5 files changed, 211 insertions(+), 16 deletions(-) diff --git a/doc/error-handling-rulebook.md b/doc/error-handling-rulebook.md index f968e2b5b..afa0dfe73 100644 --- a/doc/error-handling-rulebook.md +++ b/doc/error-handling-rulebook.md @@ -32,6 +32,10 @@ by classifying its failures: a byte a failed write never sent, so the failure path disarms it. The cost is then the wakes already in flight rather than every wake after them. Never throw from a wake path. +- A service's `shutdown()` is the same shape. Handing process-wide + state back (`sigaction`/`signal` restored to `SIG_DFL`) has no + channel and no caller left to act on one, so its result is + `std::ignore`d deliberately. - Never both channels for one operation. Never `std::error_code&` out-params. Never a throwing/non-throwing overload pair. @@ -173,8 +177,11 @@ second channel: functions with a `-DBOOST_COROSIO_DYN_LINK -DBOOST_COROSIO_SOURCE` syntax check. - Deliberate discards use `std::ignore = expr;`, never `(void)` - casts. Reserve them for calls whose outcome is asserted downstream - (hostile-input tests, best-effort bench teardown). + casts and never `[[maybe_unused]]` on a named result. Reserve them + for calls whose outcome is asserted downstream (hostile-input tests, + best-effort bench teardown), whose failure is impossible by + construction at that site, or which have no channel to report on at + all (§1's wake and `shutdown()` paths). - Unused names — parameters kept for signature clarity, structured bindings partially consumed, `#if`-gated uses — are declared `[[maybe_unused]]`, never silenced with a void cast. diff --git a/include/boost/corosio/native/detail/iocp/win_signals.hpp b/include/boost/corosio/native/detail/iocp/win_signals.hpp index adfb32bba..4a538eba2 100644 --- a/include/boost/corosio/native/detail/iocp/win_signals.hpp +++ b/include/boost/corosio/native/detail/iocp/win_signals.hpp @@ -26,6 +26,7 @@ #include #include +#include #include @@ -170,7 +171,11 @@ class BOOST_COROSIO_DECL win_signals final win_signals(win_signals const&) = delete; win_signals& operator=(win_signals const&) = delete; - /** Shut down the service. */ + /** Shut down the service. + + Destroys every implementation the service still owns and gives + each of their registrations back to the process-global table. + */ void shutdown() override; /** Destroy a signal implementation. */ @@ -397,19 +402,41 @@ inline win_signals::~win_signals() inline void win_signals::shutdown() { + signal_detail::signal_state* state = signal_detail::get_signal_state(); + std::lock_guard state_lock(state->mutex); std::lock_guard lock(mutex_); for (auto* impl = impl_list_.pop_front(); impl != nullptr; impl = impl_list_.pop_front()) { - // Clear registrations while (auto* reg = impl->signals_) { + int const signal_number = reg->signal_number; + + // The registration table outlives every io_context, so a set + // still registered here has to give its count and handler + // back the way clear() would: otherwise the handler stays + // installed for a signal no set owns any more. The per-node + // table unlink clear() also does is skipped in favour of the + // wholesale null-out below. + if (state->registration_count[signal_number] == 1) + std::ignore = ::signal(signal_number, SIG_DFL); + + --state->registration_count[signal_number]; + impl->signals_ = reg->next_in_set; delete reg; } delete impl; } + + // Every live registration hung off an implementation in impl_list_, + // so the whole table goes stale at once and can be dropped wholesale + // rather than node by node. It has to be dropped: deliver_signal() + // walks this service until the destructor unlinks it from the global + // list. + for (int i = 0; i < max_signal_number; ++i) + registrations_[i] = nullptr; } inline io_object::implementation* diff --git a/include/boost/corosio/native/detail/posix/posix_signal_service.hpp b/include/boost/corosio/native/detail/posix/posix_signal_service.hpp index 1776248b3..bd65c778d 100644 --- a/include/boost/corosio/native/detail/posix/posix_signal_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_signal_service.hpp @@ -24,6 +24,7 @@ #include #include +#include #include #include @@ -172,6 +173,11 @@ class BOOST_COROSIO_DECL posix_signal_service final destroy_impl(impl); } + /** Shut down the service. + + Destroys every implementation the service still owns and gives + each of their registrations back to the process-global table. + */ void shutdown() override; void destroy_impl(posix_signal& impl); @@ -496,6 +502,9 @@ inline posix_signal_service::~posix_signal_service() inline void posix_signal_service::shutdown() { + posix_signal_detail::signal_state* state = + posix_signal_detail::get_signal_state(); + std::lock_guard state_lock(state->mutex); std::lock_guard lock(mutex_); for (auto* impl = impl_list_.pop_front(); impl != nullptr; @@ -503,11 +512,40 @@ posix_signal_service::shutdown() { while (auto* reg = impl->signals_) { + int const signal_number = reg->signal_number; + + // The registration table outlives every io_context, so a set + // still registered here has to give its count and disposition + // back the way clear() would: otherwise the signal stays + // installed with these flags and the next add() of it is + // refused. The per-node table unlink clear() also does is + // skipped in favour of the wholesale null-out below. + if (state->registration_count[signal_number] == 1) + { + struct sigaction sa = {}; + sa.sa_handler = SIG_DFL; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + std::ignore = ::sigaction(signal_number, &sa, nullptr); + state->registered_flags[signal_number] = signal_set::none; + } + + --state->registration_count[signal_number]; + --registration_count_[signal_number]; + impl->signals_ = reg->next_in_set; delete reg; } delete impl; } + + // Every live registration hung off an implementation in impl_list_, + // so the whole table goes stale at once and can be dropped wholesale + // rather than node by node. It has to be dropped: deliver_signal() + // walks this service until the destructor unlinks it from the global + // list. + for (int i = 0; i < max_signal_number; ++i) + registrations_[i] = nullptr; } inline io_object::implementation* diff --git a/test/unit/fault/posix_faults.cpp b/test/unit/fault/posix_faults.cpp index 205294a41..7d77ce7ad 100644 --- a/test/unit/fault/posix_faults.cpp +++ b/test/unit/fault/posix_faults.cpp @@ -39,6 +39,7 @@ #include #include +#include #include namespace boost::corosio::test::fault { @@ -575,11 +576,10 @@ struct posix_common_faults } // The signal service's shutdown walks the implementations it still - // owns, deleting each set and the registrations hanging off it. A - // signal set that outlives its io_context is the only way to reach - // that walk, and the SIGINT registration it leaves behind stays in - // the process signal table and fails every later add() of the same - // signal -- so the whole thing happens in a child that dies with it. + // owns, giving back each registration hanging off them. A set held + // in an abandoned coroutine frame is the only way to reach that + // walk; the frame dies with the child rather than being reported + // against the suite. void testSignalTeardownWalk() { in_child([]{ @@ -598,7 +598,10 @@ struct posix_common_faults if(ioc.run_one() != 1) return false; } - return !resumed; + struct sigaction cur = {}; + if(::sigaction(SIGINT, nullptr, &cur) < 0) + return false; + return !resumed && cur.sa_handler == SIG_DFL; }); } diff --git a/test/unit/signal_set.cpp b/test/unit/signal_set.cpp index 41f841721..46af57705 100644 --- a/test/unit/signal_set.cpp +++ b/test/unit/signal_set.cpp @@ -25,6 +25,10 @@ #include "context.hpp" #include "test_suite.hpp" +#if BOOST_COROSIO_POSIX +#include +#endif + namespace boost::corosio { // Signal set tests @@ -364,21 +368,131 @@ struct signal_set_test void testShutdownWithPendingSignalSet() { - // Construct a signal_set that owns a signal registration, then let - // the io_context shutdown drain the impl_list (covers shutdown - // loop deleting registrations). + // A set destroyed in the documented order gives its registrations + // back through destroy(), so the service's shutdown finds an + // empty impl_list_ here; the walk itself is reached by + // testShutdownReleasesRegistration below. [[maybe_unused]] int destroyed = 0; { io_context ioc(Backend); [[maybe_unused]] signal_set s(ioc, SIGINT, SIGTERM); - // No run() — drop directly into io_context destruction so the - // service's shutdown path walks impl_list_ and frees both - // signal_registration nodes. } BOOST_TEST_PASS(); } + // The process signal table outlives every io_context, so a set still + // registered when its context shuts down -- which an abandoned frame + // is the only way to arrange -- has to hand the registration back + // there: a stale entry keeps the signal installed with the old flags + // and refuses the next add() of it. + void testShutdownReleasesRegistration() + { +#if BOOST_COROSIO_POSIX + constexpr auto parked_flags = signal_set::restart; + constexpr auto reuse_flags = signal_set::no_defer; +#else + constexpr auto parked_flags = signal_set::none; + constexpr auto reuse_flags = signal_set::none; +#endif + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + signal_set sig(ioc); + BOOST_TEST(!sig.add(SIGINT, parked_flags)); + std::ignore = co_await sig.wait(); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + // Exactly one handler, the coroutine start: anything else + // would leave the wait unparked and the set unregistered by + // the time the context is destroyed. + BOOST_TEST(ioc.run_one() == 1); + } + BOOST_TEST(!resumed); + + io_context ioc(Backend); + signal_set s(ioc); + auto add_ec = s.add(SIGINT, reuse_flags); + BOOST_TEST(!add_ec); + // Raising with no handler installed would kill the process. + if (add_ec) + return; + + // Raised before the wait starts, so nothing here is timed. + std::raise(SIGINT); + + bool completed = false; + int received_signal = 0; + + auto wait_task = [](signal_set& s_ref, int& sig_out, + bool& done_out) -> capy::task<> { + auto [ec, signum] = co_await s_ref.wait(); + sig_out = signum; + done_out = !ec; + }; + capy::run_async(ioc.get_executor())( + wait_task(s, received_signal, completed)); + + ioc.run(); + BOOST_TEST(completed); + BOOST_TEST_EQ(received_signal, SIGINT); + } + + // The walk gives back one entry, not the signal: with a second + // context registered on SIGINT the disposition has to survive the + // first context's teardown, and the survivor has to keep receiving. + void testShutdownKeepsOtherContextRegistered() + { + io_context survivor(Backend); + signal_set kept(survivor); + BOOST_TEST(!kept.add(SIGINT)); + + bool resumed = false; + { + io_context ioc(Backend); + auto keeper = [&]() -> capy::task<> { + signal_set sig(ioc); + BOOST_TEST(!sig.add(SIGINT)); + std::ignore = co_await sig.wait(); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + BOOST_TEST(ioc.run_one() == 1); + } + BOOST_TEST(!resumed); + +#if BOOST_COROSIO_POSIX + // A disposition restored out from under the survivor would make + // the raise below kill the process, so say so as a failure. + struct sigaction cur = {}; + BOOST_TEST(::sigaction(SIGINT, nullptr, &cur) == 0); + BOOST_TEST(cur.sa_handler != SIG_DFL); + if (cur.sa_handler == SIG_DFL) + return; +#endif + + // Raised before the wait starts, so nothing here is timed. + std::raise(SIGINT); + + bool completed = false; + int received_signal = 0; + + auto wait_task = [](signal_set& s_ref, int& sig_out, + bool& done_out) -> capy::task<> { + auto [ec, signum] = co_await s_ref.wait(); + sig_out = signum; + done_out = !ec; + }; + capy::run_async(survivor.get_executor())( + wait_task(kept, received_signal, completed)); + + survivor.run(); + BOOST_TEST(completed); + BOOST_TEST_EQ(received_signal, SIGINT); + } + // Multiple signal set tests void testMultipleSignalSetsOnSameSignal() @@ -916,6 +1030,12 @@ struct signal_set_test // Signal flags tests (Windows only) testFlagsNotSupportedOnWindows(); #endif + +#if !COROSIO_TEST_HAS_ASAN + // Abandon parked coroutine frames by design; see context.hpp. + testShutdownReleasesRegistration(); + testShutdownKeepsOtherContextRegistered(); +#endif } }; From 6ece5537bf7c957bfa9f5e94678031874b961ce0 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 1 Sep 2026 20:29:40 +0200 Subject: [PATCH 30/34] refactor(posix): move file ops onto the coro_op base The POSIX pool-path stream_file::file_op and random_access_file::raf_op each hand-rolled the coroutine handle, executor, output pointers, cancelled flag, stop_callback and impl keepalive that coro_op already provides. Rebase both onto coro_op so that state, the canceller wiring and start() are inherited: the per-op keepalive becomes the shared impl_ptr slot, and each op's nested canceller (which only recorded the request) is subsumed by coro_op's default on_cancel(). raf_op keeps its typed file_ raw back-pointer for the work path; only the keepalive loses its type, and it was never dereferenced as typed. Each op retains its genuinely-extra state: iovecs, iovec_count, errn, bytes_transferred, and raf_op's offset. Keepalive lifetime, virtual dispatch, cancellation and work accounting are unchanged. --- .../detail/posix/posix_random_access_file.hpp | 44 ++++---------- .../posix_random_access_file_service.hpp | 4 +- .../native/detail/posix/posix_stream_file.hpp | 58 ++++--------------- .../posix/posix_stream_file_service.hpp | 4 +- 4 files changed, 26 insertions(+), 84 deletions(-) diff --git a/include/boost/corosio/native/detail/posix/posix_random_access_file.hpp b/include/boost/corosio/native/detail/posix/posix_random_access_file.hpp index 14171106b..5dc2a961d 100644 --- a/include/boost/corosio/native/detail/posix/posix_random_access_file.hpp +++ b/include/boost/corosio/native/detail/posix/posix_random_access_file.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -75,51 +76,26 @@ class posix_random_access_file final /** Per-operation state, heap-allocated for each async call. - Inherits from scheduler_op (for scheduler completion) and - pool_work_item (for thread-pool dispatch). Linked into the - file's outstanding_ops_ list for cancellation tracking. + Inherits from `coro_op` (for scheduler completion plus the shared + coroutine, cancellation and keepalive machinery) and + `pool_work_item` (for thread-pool dispatch). Linked into the + file's outstanding_ops_ list for cancellation tracking. `coro_op` + leads the base list so a `scheduler_op*` round-trips. */ struct raf_op final - : scheduler_op + : coro_op , pool_work_item , intrusive_list::node { - struct canceller - { - raf_op* op; - void operator()() const noexcept - { - op->cancelled.store(true, std::memory_order_release); - } - }; - - std::coroutine_handle<> h; - capy::executor_ref ex; - - std::error_code* ec_out = nullptr; - std::size_t* bytes_out = nullptr; - iovec iovecs[max_buffers]; int iovec_count = 0; std::uint64_t offset = 0; int errn = 0; std::size_t bytes_transferred = 0; - bool is_read = false; - - std::atomic cancelled{false}; - std::optional> stop_cb; + // Raw back-pointer for the typed work; `impl_ptr` is the keepalive. posix_random_access_file* file_ = nullptr; - std::shared_ptr file_ref; - - void start(std::stop_token const& token) - { - cancelled.store(false, std::memory_order_release); - stop_cb.reset(); - if (token.stop_possible()) - stop_cb.emplace(token, canceller{this}); - } void operator()() override; void destroy() override; @@ -330,7 +306,7 @@ posix_random_access_file::raf_op::operator()() file_->outstanding_ops_.remove(this); } - file_ref.reset(); + impl_ptr.reset(); auto coro = h; ex.on_work_finished(); @@ -348,7 +324,7 @@ posix_random_access_file::raf_op::destroy() std::lock_guard lock(file_->ops_mutex_); file_->outstanding_ops_.remove(this); } - file_ref.reset(); + impl_ptr.reset(); ex.on_work_finished(); delete this; } diff --git a/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp b/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp index 0a4c4cdfe..ab285a4dc 100644 --- a/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_random_access_file_service.hpp @@ -207,7 +207,7 @@ posix_random_access_file::read_some_at( op->ec_out = ec; op->bytes_out = bytes_out; op->file_ = this; - op->file_ref = this->shared_from_this(); + op->impl_ptr = this->shared_from_this(); op->start(token); op->ex.on_work_started(); @@ -278,7 +278,7 @@ posix_random_access_file::write_some_at( op->ec_out = ec; op->bytes_out = bytes_out; op->file_ = this; - op->file_ref = this->shared_from_this(); + op->impl_ptr = this->shared_from_this(); op->start(token); op->ex.on_work_started(); diff --git a/include/boost/corosio/native/detail/posix/posix_stream_file.hpp b/include/boost/corosio/native/detail/posix/posix_stream_file.hpp index 73144f722..f827582e8 100644 --- a/include/boost/corosio/native/detail/posix/posix_stream_file.hpp +++ b/include/boost/corosio/native/detail/posix/posix_stream_file.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -90,27 +91,13 @@ class posix_stream_file final public: static constexpr std::size_t max_buffers = 16; - /** Operation state for a single file read or write. */ - struct file_op : scheduler_op - { - struct canceller - { - file_op* op; - void operator()() const noexcept - { - op->request_cancel(); - } - }; - - // Coroutine state - std::coroutine_handle<> h; - capy::continuation cont; - capy::executor_ref ex; - - // Output pointers - std::error_code* ec_out = nullptr; - std::size_t* bytes_out = nullptr; + /** Operation state for a single file read or write. + The coroutine, cancellation and keepalive machinery is inherited + from `coro_op`; only the pool-path result state lives here. + */ + struct file_op : coro_op + { // Buffer data (copied from buffer_param at submission time) iovec iovecs[max_buffers]; int iovec_count = 0; @@ -118,14 +105,6 @@ class posix_stream_file final // Result storage (populated by worker thread) int errn = 0; std::size_t bytes_transferred = 0; - bool is_read = false; - - // Thread coordination - std::atomic cancelled{false}; - std::optional> stop_cb; - - /// Prevents use-after-free when file is closed with pending ops. - std::shared_ptr impl_ref; file_op() = default; @@ -137,26 +116,13 @@ class posix_stream_file final is_read = false; cancelled.store(false, std::memory_order_relaxed); stop_cb.reset(); - impl_ref.reset(); + impl_ptr.reset(); ec_out = nullptr; bytes_out = nullptr; } void operator()() override; void destroy() override; - - void request_cancel() noexcept - { - cancelled.store(true, std::memory_order_release); - } - - void start(std::stop_token const& token) - { - cancelled.store(false, std::memory_order_release); - stop_cb.reset(); - if (token.stop_possible()) - stop_cb.emplace(token, canceller{this}); - } }; /** Pool work item for thread pool dispatch. */ @@ -428,10 +394,10 @@ posix_stream_file::file_op::operator()() if (bytes_out) *bytes_out = was_cancelled ? 0 : bytes_transferred; - // Move impl_ref to a local so members remain valid through - // dispatch — impl_ref may be the last shared_ptr keeping + // Move impl_ptr to a local so members remain valid through + // dispatch — impl_ptr may be the last shared_ptr keeping // the parent posix_stream_file (which embeds this file_op) alive. - auto prevent_destroy = std::move(impl_ref); + auto prevent_destroy = std::move(impl_ptr); ex.on_work_finished(); cont.h = h; dispatch_coro(ex, cont).resume(); @@ -442,7 +408,7 @@ posix_stream_file::file_op::destroy() { stop_cb.reset(); auto local_ex = ex; - impl_ref.reset(); + impl_ptr.reset(); local_ex.on_work_finished(); } diff --git a/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp b/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp index 543216e87..44045f9ae 100644 --- a/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp +++ b/include/boost/corosio/native/detail/posix/posix_stream_file_service.hpp @@ -258,7 +258,7 @@ posix_stream_file::do_read_work(pool_work_item* w) noexcept } } - op.impl_ref = std::move(pw->ref_); + op.impl_ptr = std::move(pw->ref_); self->svc_.post(&op); } @@ -359,7 +359,7 @@ posix_stream_file::do_write_work(pool_work_item* w) noexcept } } - op.impl_ref = std::move(pw->ref_); + op.impl_ptr = std::move(pw->ref_); self->svc_.post(&op); } From 8ae93552d2aad65296b27c9c04005fdc4b4f2751 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 1 Sep 2026 22:41:10 +0200 Subject: [PATCH 31/34] fix(io_uring): name the error a POLL_ADD error band carries A wait(error) satisfied by IORING_OP_POLL_ADD reports the readiness in the CQE's res as revents, so res stays >= 0 and the handler produced an empty error_code -- indistinguishable from a benign readiness signal, where epoll/select/kqueue/IOCP all deliver a named code. When the revents carry POLLERR/POLLHUP/POLLNVAL, probe SO_ERROR and complete with that (EIO when the kernel exposes none), mirroring the reactor and the IOCP wait reactor. Out-of-band data (POLLPRI) stays a readiness signal, not an error. --- .../detail/io_uring/io_uring_socket_ops.hpp | 24 +++++++- test/unit/wait.cpp | 57 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp index 16e9c1d2b..00ced752d 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_socket_ops.hpp @@ -31,6 +31,7 @@ #include +#include #include #include #include @@ -650,11 +651,32 @@ struct uring_wait_op : io_uring_op if (self->sched_) self->sched_->reset_inline_budget(); + // A POLL_ADD completion carries the error band in its revents + // (res), not as a negative res, so name the reason the reactor + // way — SO_ERROR, or EIO when the kernel has none — instead of + // completing wait(error) with an empty, benign-looking code. + // OOB (POLLPRI) is a readiness signal, not an error. + std::error_code ec{}; + if (self->res < 0) + { + ec = make_err(-self->res); + } + else if (self->res & (POLLERR | POLLHUP | POLLNVAL)) + { + int so_err = 0; + socklen_t len = sizeof(so_err); + if (::getsockopt(self->fd, SOL_SOCKET, SO_ERROR, &so_err, &len) < 0) + so_err = errno; + if (so_err == 0) + so_err = EIO; + ec = make_err(so_err); + } + // Wait reports only success/cancel/error — no bytes, no EOF. decode_io_result( self->ec_out, self->cancelled.load(std::memory_order_acquire), - self->res < 0 ? make_err(-self->res) : std::error_code{}, + ec, /*is_read=*/false, /*bytes=*/0, /*empty_buffer=*/false); coro_resume(self); diff --git a/test/unit/wait.cpp b/test/unit/wait.cpp index 554209642..a79ca776e 100644 --- a/test/unit/wait.cpp +++ b/test/unit/wait.cpp @@ -1045,4 +1045,61 @@ struct wait_closed_test COROSIO_BACKEND_TESTS(wait_closed_test, "boost.corosio.wait_closed") +// A faulted socket's wait(error) must name why it faulted: the delivered +// error_code must be a real, non-empty code (SO_ERROR, e.g. +// connection_reset), never an empty error_code — which is +// indistinguishable from a benign readiness signal — and never the +// canceled condition. +// +// Scoped to epoll (the control, which reads SO_ERROR and names the code) +// and io_uring (which completes the POLL_ADD with res>=0 and therefore an +// empty error_code — the bug). Not run on select, where a peer RST does +// not set except_fds and the error wait would never fire, nor on kqueue, +// which is not exercised on this host. +#if BOOST_COROSIO_HAS_EPOLL && BOOST_COROSIO_HAS_IO_URING +struct error_wait_names_reset_test +{ + template + void check() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + // Both ends linger with a zero timeout, so closing the peer + // sends an RST rather than a graceful FIN. + auto [s1, s2] = test::make_socket_pair(ioc); + + std::error_code wait_ec; + bool wait_done = false; + + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::error); + wait_ec = ec; + wait_done = true; + }; + // Spawn order is park order: the error wait is outstanding + // before the peer's RST reaches the socket. + auto resetter = [&]() -> capy::task<> { + s2.close(); + co_return; + }; + + capy::run_async(ex)(waiter()); + capy::run_async(ex)(resetter()); + ioc.run(); + + BOOST_TEST(wait_done); + BOOST_TEST(wait_ec); + BOOST_TEST(wait_ec != capy::cond::canceled); + } + + void run() + { + check(); // control: names the code, passes + check(); // bug D1: empty error_code, fails + } +}; + +TEST_SUITE(error_wait_names_reset_test, "boost.corosio.wait_error_reset"); +#endif + } // namespace boost::corosio From 7f3c43ee776ffc2fa7d6de17d942fe74b6dd26cb Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 1 Sep 2026 22:41:29 +0200 Subject: [PATCH 32/34] fix(select): reject an out-of-range accept with EMFILE An accepted descriptor at or above FD_SETSIZE cannot be monitored by select(), the same logical failure the adoption (validate_assigned_fd) and creation (set_fd_options) range checks already report as EMFILE. The accept path reported EINVAL instead; make it EMFILE so a caller distinguishing "too many files" from "bad argument" sees one answer for one condition. --- .../boost/corosio/native/detail/select/select_traits.hpp | 2 +- test/unit/fault/select_faults.cpp | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/include/boost/corosio/native/detail/select/select_traits.hpp b/include/boost/corosio/native/detail/select/select_traits.hpp index f3e6354a5..b36e0274e 100644 --- a/include/boost/corosio/native/detail/select/select_traits.hpp +++ b/include/boost/corosio/native/detail/select/select_traits.hpp @@ -128,7 +128,7 @@ struct select_traits if (new_fd >= FD_SETSIZE) { ::close(new_fd); - errno = EINVAL; + errno = EMFILE; return -1; } diff --git a/test/unit/fault/select_faults.cpp b/test/unit/fault/select_faults.cpp index 08418ca6b..57c80603b 100644 --- a/test/unit/fault/select_faults.cpp +++ b/test/unit/fault/select_faults.cpp @@ -360,7 +360,11 @@ struct select_faults skip_no_high_fd("testAcceptAboveFdSetsize"); return; } - BOOST_TEST(aec == std::errc::invalid_argument); + // A descriptor out of select's addressable range is the same + // logical failure the adoption (validate_assigned_fd) and + // creation (set_fd_options) checks report as too_many_files_open; + // the accept path must answer with the same code, not EINVAL. + BOOST_TEST(aec == std::errc::too_many_files_open); BOOST_TEST(!server.is_open()); BOOST_TEST_EQ(leaked, 0); } From 5e649e75529afd57b87d4a7f61f455485f5762d6 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 1 Sep 2026 22:41:42 +0200 Subject: [PATCH 33/34] fix(reactor): do not fault a healthy read on select's out-of-band set select() raises its exceptional set for TCP urgent/out-of-band data as well as for genuine faults, and the dispatch folded that set into the error condition and synthesized EIO when SO_ERROR came back zero. A pending read or write on an otherwise-healthy socket carrying an urgent byte then completed with EIO on select, where epoll (EPOLLPRI is not the error bit) and kqueue (OOB is not EV_ERROR) return the data cleanly. Out-of-band data is a readiness condition, not an error: an I/O operation now completes on a real (non-zero) SO_ERROR only, so a genuine fault still surfaces through the probe every reactor backend uses. Only wait(error) keeps the EIO fallback, so a wait that fires on the exceptional condition still names a code. Fixing the read path also corrects the write-direction fault-injection tests, which were asserting the spurious EIO on a socket that was in no way broken. --- .../reactor/reactor_descriptor_state.hpp | 14 +++-- test/unit/fault/reactor_faults.hpp | 53 +++++++------------ test/unit/wait.cpp | 51 ++++++++++++++++++ 3 files changed, 80 insertions(+), 38 deletions(-) diff --git a/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp b/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp index 679cfd3fd..cf0a0c41f 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp @@ -171,8 +171,12 @@ reactor_descriptor_state::invoke_deferred_io() socklen_t len = sizeof(err); if (::getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) < 0) err = errno; - if (err == 0) - err = EIO; + // select raises its exceptional set for out-of-band/urgent + // data as well as for genuine faults; on a healthy socket the + // probe then reads SO_ERROR == 0. Faulting a pending read or + // write on that is wrong, so an I/O operation completes only + // on a real (non-zero) error. wait(error) still names a code + // below. } if (ev & reactor_event_read) @@ -295,7 +299,11 @@ reactor_descriptor_state::invoke_deferred_io() { if (wait_error_op) { - wait_error_op->complete(err, 0); + // wait(error) fired on the exceptional condition; name a + // code even when the kernel exposed none (e.g. urgent + // data leaves SO_ERROR == 0). + int const werr = err ? err : EIO; + wait_error_op->complete(werr, 0); local_ops.push(std::exchange(wait_error_op, nullptr)); } } diff --git a/test/unit/fault/reactor_faults.hpp b/test/unit/fault/reactor_faults.hpp index 61ff10daf..8b1dba868 100644 --- a/test/unit/fault/reactor_faults.hpp +++ b/test/unit/fault/reactor_faults.hpp @@ -797,35 +797,20 @@ struct reactor_common_faults "on this kernel; skipping %s\n", what); } - /* Report a premise that held for the probe and not for the reactor. + /* The write-direction dispatch when the except set is raised + without a socket error. - Only the round before the reactor's can be probed from a - coroutine, so a kernel that drops one of the two bits in between - leaves the operation resolving normally -- the arm never ran, and - there is nothing here to assert about. - */ - static void skip_unpaired(char const* what) - { - std::fprintf(stderr, - "fault harness: the reactor's round did not carry both " - "writability and the except set; skipping %s\n", what); - } - - /* The write-direction error arms, reached without a socket error. - - Both arms need the same round to report writability and an error - condition on one descriptor, and a reset does not do that on the - BSD family -- it surfaces as plain writability, so the operation - re-runs its I/O and reports the real error instead. An urgent - byte does: it raises select's except set on a descriptor that is + Both arms need the same round to report writability and an + exceptional condition on one descriptor. An urgent byte does + that: it raises select's except set on a descriptor that is writable in its own right. - The SO_ERROR probe is left unfaulted here on purpose. An - out-of-band condition leaves no socket error behind, so the probe - reads back zero and the dispatch substitutes EIO - (reactor_descriptor_state::invoke_deferred_io) -- which is also - what the operation reports, on a socket that is in no way - broken. + The SO_ERROR probe is left unfaulted on purpose. Out-of-band data + leaves no socket error behind, so the probe reads back zero -- and + a healthy socket must not be faulted for it + (reactor_descriptor_state::invoke_deferred_io). The write simply + re-runs its I/O and completes normally, on a socket that is in no + way broken. */ void testErrorEventOnWritableWrite() { @@ -850,8 +835,8 @@ struct reactor_common_faults std::ignore = n; wec = ec; spec_fired = spec.fired(); - // Nothing broke: the condition the dispatch reported - // was one byte of urgent data. + // Nothing broke: the condition was one byte of urgent + // data, so the write completes normally. open_after = c.is_open(); // The byte is never consumed, so the except set stays // raised; the descriptor that raised it goes before the @@ -877,7 +862,7 @@ struct reactor_common_faults skip_unraisable("testErrorEventOnWritableWrite"); return; } - BOOST_TEST(wec == std::errc::io_error); + BOOST_TEST(!wec); BOOST_TEST(open_after); } } @@ -930,12 +915,10 @@ struct reactor_common_faults skip_unraisable("testErrorEventOnWritableWaitWrite"); return; } - if(!wec) - { - skip_unpaired("testErrorEventOnWritableWaitWrite"); - return; - } - BOOST_TEST(wec == std::errc::io_error); + // Urgent data on a writable socket is not a fault: the wait + // completes on the writability, never with an error. + BOOST_TEST(done); + BOOST_TEST(!wec); BOOST_TEST(open_after); } } diff --git a/test/unit/wait.cpp b/test/unit/wait.cpp index a79ca776e..b16b6c029 100644 --- a/test/unit/wait.cpp +++ b/test/unit/wait.cpp @@ -935,6 +935,54 @@ struct wait_test local_endpoint(tmp.path())); } +#if BOOST_COROSIO_POSIX + // TCP urgent (out-of-band) data is a readiness condition, not a + // fault: a read on an otherwise-healthy socket that also carries an + // urgent byte must return the normal data, never io_error. select + // maps the urgent byte onto except_fds -> reactor_event_error and, + // with SO_ERROR still zero, synthesizes EIO; epoll/kqueue/io_uring do + // not treat OOB as an error. + void testOobDoesNotFaultRead() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = test::make_socket_pair(ioc); + + std::error_code read_ec; + std::size_t bytes_read = 0; + bool read_done = false; + std::array buf{}; + + auto reader = [&]() -> capy::task<> { + auto [ec, n] = co_await s1.read_some( + capy::mutable_buffer(buf.data(), buf.size())); + read_ec = ec; + bytes_read = n; + read_done = true; + }; + // Spawn order is park order: the read parks before the writer's + // urgent byte trips the exceptional condition and the normal + // byte satisfies the read. + auto writer = [&]() -> capy::task<> { + int fd = static_cast(s2.native_handle()); + char urg = '!'; + ::send(fd, &urg, 1, MSG_OOB); + char normal = 'x'; + ::send(fd, &normal, 1, 0); + co_return; + }; + + capy::run_async(ex)(reader()); + capy::run_async(ex)(writer()); + ioc.run(); + + BOOST_TEST(read_done); + BOOST_TEST(read_ec != std::errc::io_error); + BOOST_TEST(!read_ec); + BOOST_TEST_EQ(bytes_read, 1u); + } +#endif + void run() { testWaitReadAndNoConsume(); @@ -944,6 +992,9 @@ struct wait_test testWaitWriteCancelDoesNotLeak(); testAcceptorWait(); testAcceptorErrorWaitCancel(); +#if BOOST_COROSIO_POSIX + testOobDoesNotFaultRead(); +#endif testWaitOnLocalStream(); testWaitOnUdp(); testWaitReadAfterShortRead(); From 72f550f001885db4444154d8e3a27986632ff19d Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Tue, 1 Sep 2026 22:41:53 +0200 Subject: [PATCH 34/34] fix(iocp): keep the forward resolver alive across its async completion The forward GetAddrInfoExW path was the only resolver path (of the IOCP reverse path and both POSIX paths) that held no impl keepalive across its completion. The op is embedded in the win_resolver, its OS callback calls work_finished() before posting the completion, and the completion then waits in the scheduler queue -- so abandoning or tearing down a context with a forward lookup in flight could free the win_resolver before the queued op drained, a use-after-free. Mirror the reverse path: take shared_from_this() into the op's impl_ptr at initiation and move it out on both do_complete arms -- a suicide local on the drain arm, held across the dispatch on the resume arm -- so the implementation outlives the queued completion. --- .../detail/iocp/win_resolver_service.hpp | 12 +++++ test/unit/resolver.cpp | 47 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp b/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp index 6d01f3966..4ef7b3eb0 100644 --- a/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_resolver_service.hpp @@ -257,6 +257,9 @@ resolve_op::do_complete( op->results = nullptr; } op->cancel_handle = nullptr; + // Dropping the keepalive may destroy the implementation this op + // is embedded in, so nothing may touch it afterwards. + auto suicide = std::move(op->impl_ptr); return; } @@ -288,6 +291,9 @@ resolve_op::do_complete( op->cancel_handle = nullptr; op->cont.h = op->h; + // Hold the keepalive across the dispatch: it may be the last + // reference to the implementation this op is embedded in. + auto prevent_destroy = std::move(op->impl_ptr); dispatch_coro(op->ex, op->cont).resume(); } @@ -381,6 +387,12 @@ win_resolver::resolve( // Keep io_context alive while resolution is pending svc_.work_started(); + // Prevent impl destruction while the async resolve is in flight and + // its completion waits in the scheduler queue: the op is embedded in + // this win_resolver, which teardown may otherwise free before the + // queued completion drains. Mirrors the reverse path's keepalive. + op.impl_ptr = this->shared_from_this(); + int result = ::GetAddrInfoExW( op.host_w.empty() ? nullptr : op.host_w.c_str(), op.service_w.empty() ? nullptr : op.service_w.c_str(), NS_DNS, nullptr, diff --git a/test/unit/resolver.cpp b/test/unit/resolver.cpp index c7cfaeed2..bafcb80da 100644 --- a/test/unit/resolver.cpp +++ b/test/unit/resolver.cpp @@ -1184,6 +1184,49 @@ struct resolver_test BOOST_TEST(!resumed); } +#if BOOST_COROSIO_HAS_IOCP + // Forward resolution on IOCP dispatches through GetAddrInfoExW and + // posts its completion (resolve_op, embedded in the win_resolver) to + // the scheduler queue. Unlike the reverse path and both POSIX paths, + // the forward op takes no shared_from_this()/impl_ptr keepalive, so + // destroying the resolver frees the win_resolver the queued + // resolve_op lives in before teardown drains that op -- a + // use-after-free that ASan catches. localhost resolves from the hosts + // file synchronously, so GetAddrInfoExW posts the op inline and it is + // reliably queued when the resolver is freed. + // + // The task is started by hand and owned by the test, so the frame the + // library abandons at teardown is destroyed here rather than leaked: + // what a sanitizer reports is the defect alone. The bug is observable + // only under ASan; without a sanitizer the freed read is silent. + // + // Contrast the reverse path, which holds the keepalive on + // reverse_op_.impl_ptr across the queued completion + // (win_resolver_service.hpp do_reverse_resolve_work / do_complete), + // and so survives teardown intact. + void testDestroyWithForwardResolveQueued() + { + bool resumed = false; + std::optional ex; + std::optional env; + std::optional> parked; + { + io_context ioc; + resolver r(ioc); + auto query = [&]() -> capy::task<> { + std::ignore = co_await r.resolve("localhost", "80"); + resumed = true; + }; + + ex.emplace(ioc.get_executor()); + env.emplace(capy::io_env{*ex, std::stop_token{}, nullptr}); + parked.emplace(query()); + parked->await_suspend(std::noop_coroutine(), &*env).resume(); + } + BOOST_TEST(!resumed); + } +#endif + void run() { // Construction and move semantics @@ -1254,6 +1297,10 @@ struct resolver_test #endif testDestroyWithPoolReverseQueued(); +#if BOOST_COROSIO_HAS_IOCP + testDestroyWithForwardResolveQueued(); +#endif + #if !COROSIO_TEST_HAS_ASAN // Abandon parked coroutine frames by design; see context.hpp. testDestroyWithLiveResolver();