From 3f86674c71b9546d783efd271684d234c96a9f97 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 18:38:11 +0200 Subject: [PATCH 01/29] test(coverage): exercise deferred completions when the inline budget is spent --- test/unit/inline_budget.cpp | 697 ++++++++++++++++++++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 test/unit/inline_budget.cpp diff --git a/test/unit/inline_budget.cpp b/test/unit/inline_budget.cpp new file mode 100644 index 000000000..d69e9f40a --- /dev/null +++ b/test/unit/inline_budget.cpp @@ -0,0 +1,697 @@ +// +// 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 +// + +// Operations whose result is known synchronously normally resume the +// awaiting coroutine inline, rationed by a per-run budget. Once the +// budget is spent the completion must instead be deferred through the +// scheduler's completed-op queue. These tests pin the deferred path: +// suite A disables the reactor budget outright via io_context_options, +// suite B drains the io_uring budget with long chains of synchronously +// completing ops so the tail of each chain is forced through deferral. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#if BOOST_COROSIO_POSIX +#include +#include +#include +#include + +#include +#endif + +#include "context.hpp" +#include "test_suite.hpp" + +namespace boost::corosio { + +namespace { + +// Enough sequential sync-result ops to drain any budget configuration +// (the largest budget any scheduler hands out is 16 per dispatch). +[[maybe_unused]] constexpr int budget_cycles = 20; + +} // namespace + +template +struct budget_disabled_test +{ + static io_context + make_ioc() + { + io_context_options opts; + opts.inline_budget_max = 0; + return io_context(Backend, opts); + } + + void + testStreamSyncOpsDefer() + { + auto ioc = make_ioc(); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + std::error_code wec; + std::size_t wn = 0; + auto writer = [&]() -> capy::task<> { + auto [ec, n] = co_await s1.write_some(capy::const_buffer("abcd", 4)); + wec = ec; + wn = n; + }; + capy::run_async(ex)(writer()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!wec); + BOOST_TEST_EQ(wn, 4u); + + char buf[16]; + std::error_code rec, wtec; + std::size_t rn = 0; + auto reader = [&]() -> capy::task<> { + auto [wt_ec] = co_await s2.wait(wait_type::read); + wtec = wt_ec; + auto [ec, n] = + co_await s2.read_some(capy::mutable_buffer(buf, sizeof(buf))); + rec = ec; + rn = n; + }; + capy::run_async(ex)(reader()); + ioc.run(); + BOOST_TEST(!wtec); + BOOST_TEST(!rec); + BOOST_TEST_EQ(rn, 4u); + } + + void + testDatagramSyncOpsDefer() + { + auto ioc = make_ioc(); + auto ex = ioc.get_executor(); + + udp_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open(udp::v4())); + BOOST_TEST(!s2.open(udp::v4())); + BOOST_TEST(!s1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!s2.bind(endpoint(ipv4_address::loopback(), 0))); + auto ep1 = s1.local_endpoint(); + auto ep2 = s2.local_endpoint(); + + int ok = 0; + auto sender = [&]() -> capy::task<> { + // send_to before connect: BSD rejects an explicit + // destination on a connected datagram socket. + auto [tec, tn] = co_await s2.send_to(capy::const_buffer("y", 1), ep1); + if (!tec && tn == 1) + ++ok; + auto [cec] = co_await s2.connect(ep1); + if (!cec) + ++ok; + auto [ec, n] = co_await s2.send(capy::const_buffer("x", 1)); + if (!ec && n == 1) + ++ok; + }; + capy::run_async(ex)(sender()); + ioc.run(); + ioc.restart(); + BOOST_TEST_EQ(ok, 3); + + char buf[8]; + endpoint source; + ok = 0; + auto receiver = [&]() -> capy::task<> { + auto [wec] = co_await s1.wait(wait_type::read); + if (!wec) + ++ok; + auto [ec, n] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + if (!ec && n == 1) + ++ok; + auto [ec2, n2] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + if (!ec2 && n2 == 1) + ++ok; + }; + capy::run_async(ex)(receiver()); + ioc.run(); + BOOST_TEST_EQ(ok, 3); + BOOST_TEST(source == ep2); + } + +#if BOOST_COROSIO_POSIX + void + testLocalSyncOpsDefer() + { + auto ioc = make_ioc(); + auto ex = ioc.get_executor(); + + local_stream_socket a(ioc), b(ioc); + if (auto ec = connect_pair(a, b)) + throw std::system_error(ec, "connect_pair"); + local_datagram_socket da(ioc), db(ioc); + if (auto ec = connect_pair(da, db)) + throw std::system_error(ec, "connect_pair"); + + char buf[8]; + int ok = 0; + auto driver = [&]() -> capy::task<> { + auto [wec, wn] = co_await a.write_some(capy::const_buffer("hi", 2)); + if (!wec && wn == 2) + ++ok; + auto [rec, rn] = + co_await b.read_some(capy::mutable_buffer(buf, sizeof(buf))); + if (!rec && rn == 2) + ++ok; + auto [sec, sn] = co_await da.send(capy::const_buffer("z", 1)); + if (!sec && sn == 1) + ++ok; + auto [dec, dn] = + co_await db.recv(capy::mutable_buffer(buf, sizeof(buf))); + if (!dec && dn == 1) + ++ok; + }; + capy::run_async(ex)(driver()); + ioc.run(); + BOOST_TEST_EQ(ok, 4); + } +#endif + + void + run() + { + testStreamSyncOpsDefer(); + testDatagramSyncOpsDefer(); +#if BOOST_COROSIO_POSIX + testLocalSyncOpsDefer(); +#endif + } +}; + +COROSIO_REACTOR_BACKEND_TESTS(budget_disabled_test, "boost.corosio.budget_disabled") + +#if BOOST_COROSIO_HAS_IO_URING + +struct io_uring_budget_test +{ + // Interleaved cycles: wherever the deferral boundary lands, every op + // kind in the cycle crosses it at some iteration. + + void + testStreamStoppedOpsDefer() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + auto peer = s1.remote_endpoint(); + + std::stop_source ss; + ss.request_stop(); + + char buf[8]; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + for (int i = 0; i < budget_cycles; ++i) + { + auto [rec, rn] = co_await s1.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = rn; + if (rec == capy::cond::canceled) + ++canceled; + auto [wec, wn] = + co_await s1.write_some(capy::const_buffer("x", 1)); + std::ignore = wn; + if (wec == capy::cond::canceled) + ++canceled; + auto [cec] = co_await s1.connect(peer); + if (cec == capy::cond::canceled) + ++canceled; + } + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 3 * budget_cycles); + } + + void + testStreamSuccessOpsDefer() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + char big[64] = {}; + std::error_code pec; + auto preload = [&]() -> capy::task<> { + auto [ec, n] = + co_await s2.write_some(capy::const_buffer(big, sizeof(big))); + std::ignore = n; + pec = ec; + }; + capy::run_async(ex)(preload()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!pec); + + char c; + int ok = 0; + auto driver = [&]() -> capy::task<> { + for (int i = 0; i < budget_cycles; ++i) + { + auto [rec, rn] = + co_await s1.read_some(capy::mutable_buffer(&c, 1)); + if (!rec && rn == 1) + ++ok; + auto [wec, wn] = + co_await s1.write_some(capy::const_buffer("y", 1)); + if (!wec && wn == 1) + ++ok; + } + }; + capy::run_async(ex)(driver()); + ioc.run(); + BOOST_TEST_EQ(ok, 2 * budget_cycles); + } + + void + testUdpStoppedOpsDefer() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + udp_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open(udp::v4())); + BOOST_TEST(!s2.open(udp::v4())); + BOOST_TEST(!s1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!s2.bind(endpoint(ipv4_address::loopback(), 0))); + auto ep2 = s2.local_endpoint(); + + std::error_code cec; + auto connecter = [&]() -> capy::task<> { + auto [ec] = co_await s1.connect(ep2); + cec = ec; + }; + capy::run_async(ex)(connecter()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!cec); + + std::stop_source ss; + ss.request_stop(); + + char buf[8]; + endpoint source; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + for (int i = 0; i < budget_cycles; ++i) + { + auto [aec, an] = + co_await s1.send_to(capy::const_buffer("x", 1), ep2); + std::ignore = an; + if (aec == capy::cond::canceled) + ++canceled; + auto [bec, bn] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + std::ignore = bn; + if (bec == capy::cond::canceled) + ++canceled; + auto [dec] = co_await s1.connect(ep2); + if (dec == capy::cond::canceled) + ++canceled; + auto [eec, en] = co_await s1.send(capy::const_buffer("x", 1)); + std::ignore = en; + if (eec == capy::cond::canceled) + ++canceled; + auto [fec, fn] = + co_await s1.recv(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = fn; + if (fec == capy::cond::canceled) + ++canceled; + } + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 5 * budget_cycles); + } + + void + testUdpSuccessOpsDefer() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + udp_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open(udp::v4())); + BOOST_TEST(!s2.open(udp::v4())); + BOOST_TEST(!s1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!s2.bind(endpoint(ipv4_address::loopback(), 0))); + auto ep1 = s1.local_endpoint(); + auto ep2 = s2.local_endpoint(); + + BOOST_TEST_NO_THROW( + s1.set_option(socket_option::receive_buffer_size(1 << 20))); + + int preloaded = 0; + auto preload = [&]() -> capy::task<> { + auto [cec] = co_await s2.connect(ep1); + std::ignore = cec; + for (int i = 0; i < 5 * budget_cycles; ++i) + { + auto [ec, n] = co_await s2.send(capy::const_buffer("d", 1)); + if (!ec && n == 1) + ++preloaded; + } + }; + capy::run_async(ex)(preload()); + ioc.run(); + ioc.restart(); + BOOST_TEST_EQ(preloaded, 5 * budget_cycles); + + std::error_code cec; + auto connecter = [&]() -> capy::task<> { + auto [ec] = co_await s1.connect(ep2); + cec = ec; + }; + capy::run_async(ex)(connecter()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!cec); + + // Consecutive runs of one op kind: whatever the budget window, + // a run longer than the largest budget defers at least once. + char buf[8]; + endpoint source; + int ok = 0; + auto driver = [&]() -> capy::task<> { + for (int i = 0; i < 2 * budget_cycles; ++i) + { + auto [ec, n] = + co_await s1.recv(capy::mutable_buffer(buf, sizeof(buf))); + if (!ec && n == 1) + ++ok; + } + for (int i = 0; i < 2 * budget_cycles; ++i) + { + auto [ec, n] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + if (!ec && n == 1) + ++ok; + } + for (int i = 0; i < budget_cycles; ++i) + { + auto [ec, n] = co_await s1.send(capy::const_buffer("u", 1)); + if (!ec && n == 1) + ++ok; + } + for (int i = 0; i < budget_cycles; ++i) + { + auto [ec] = co_await s1.connect(ep2); + if (!ec) + ++ok; + } + }; + capy::run_async(ex)(driver()); + ioc.run(); + BOOST_TEST_EQ(ok, 6 * budget_cycles); + BOOST_TEST(source == ep2); + } + +#if BOOST_COROSIO_POSIX + void + testLocalStreamStoppedOpsDefer() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + local_stream_socket a(ioc), b(ioc); + if (auto ec = connect_pair(a, b)) + throw std::system_error(ec, "connect_pair"); + + test::temp_socket_dir tmp; + auto target = local_endpoint(tmp.path()); + + std::stop_source ss; + ss.request_stop(); + + char buf[8]; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + for (int i = 0; i < budget_cycles; ++i) + { + auto [rec, rn] = co_await a.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = rn; + if (rec == capy::cond::canceled) + ++canceled; + auto [wec, wn] = + co_await a.write_some(capy::const_buffer("x", 1)); + std::ignore = wn; + if (wec == capy::cond::canceled) + ++canceled; + auto [cec] = co_await a.connect(target); + if (cec == capy::cond::canceled) + ++canceled; + } + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 3 * budget_cycles); + } + + void + testLocalStreamSuccessOpsDefer() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + local_stream_socket a(ioc), b(ioc); + if (auto ec = connect_pair(a, b)) + throw std::system_error(ec, "connect_pair"); + + char big[64] = {}; + std::error_code pec; + auto preload = [&]() -> capy::task<> { + auto [ec, n] = + co_await b.write_some(capy::const_buffer(big, sizeof(big))); + std::ignore = n; + pec = ec; + }; + capy::run_async(ex)(preload()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!pec); + + char c; + int ok = 0; + auto driver = [&]() -> capy::task<> { + for (int i = 0; i < budget_cycles; ++i) + { + auto [rec, rn] = + co_await a.read_some(capy::mutable_buffer(&c, 1)); + if (!rec && rn == 1) + ++ok; + auto [wec, wn] = + co_await a.write_some(capy::const_buffer("y", 1)); + if (!wec && wn == 1) + ++ok; + } + }; + capy::run_async(ex)(driver()); + ioc.run(); + BOOST_TEST_EQ(ok, 2 * budget_cycles); + } + + void + testLocalDatagramStoppedOpsDefer() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + test::temp_socket_dir tmp1; + test::temp_socket_dir tmp2; + local_datagram_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open()); + BOOST_TEST(!s2.open()); + BOOST_TEST(!s1.bind(local_endpoint(tmp1.path()))); + BOOST_TEST(!s2.bind(local_endpoint(tmp2.path()))); + auto ep2 = local_endpoint(tmp2.path()); + + std::error_code cec; + auto connecter = [&]() -> capy::task<> { + auto [ec] = co_await s1.connect(ep2); + cec = ec; + }; + capy::run_async(ex)(connecter()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!cec); + + std::stop_source ss; + ss.request_stop(); + + char buf[8]; + local_endpoint source; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + for (int i = 0; i < budget_cycles; ++i) + { + auto [aec, an] = co_await s1.send(capy::const_buffer("x", 1)); + std::ignore = an; + if (aec == capy::cond::canceled) + ++canceled; + auto [bec, bn] = + co_await s1.recv(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = bn; + if (bec == capy::cond::canceled) + ++canceled; + auto [dec] = co_await s1.connect(ep2); + if (dec == capy::cond::canceled) + ++canceled; + auto [eec, en] = + co_await s1.send_to(capy::const_buffer("x", 1), ep2); + std::ignore = en; + if (eec == capy::cond::canceled) + ++canceled; + auto [fec, fn] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + std::ignore = fn; + if (fec == capy::cond::canceled) + ++canceled; + } + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 5 * budget_cycles); + } + + void + testLocalDatagramSuccessOpsDefer() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + test::temp_socket_dir tmp1; + test::temp_socket_dir tmp2; + local_datagram_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open()); + BOOST_TEST(!s2.open()); + BOOST_TEST(!s1.bind(local_endpoint(tmp1.path()))); + BOOST_TEST(!s2.bind(local_endpoint(tmp2.path()))); + auto ep1 = local_endpoint(tmp1.path()); + auto ep2 = local_endpoint(tmp2.path()); + + // A datagram socket's queue depth is bounded by the unix + // datagram limit, not SO_RCVBUF, so only a few can be held + // unread. Spend the inline budget on sends and connects first; + // every recv that follows in the same frame then takes the + // deferred path even though only a handful are buffered. + int const held = 6; + int preloaded = 0; + auto preload = [&]() -> capy::task<> { + for (int i = 0; i < held; ++i) + { + auto [ec, n] = + co_await s2.send_to(capy::const_buffer("d", 1), ep1); + if (!ec && n == 1) + ++preloaded; + } + }; + capy::run_async(ex)(preload()); + ioc.run(); + ioc.restart(); + BOOST_TEST_EQ(preloaded, held); + + char buf[8]; + local_endpoint source; + int ok = 0; + auto driver = [&]() -> capy::task<> { + // Spend the budget with connects: a datagram connect is a + // local re-point, always inline, and queues nothing (a + // send_to run would overflow the unconsumed peer queue and + // start failing with EAGAIN). + for (int i = 0; i < budget_cycles; ++i) + { + auto [ec] = co_await s1.connect(ep2); + if (!ec) + ++ok; + } + // Budget spent: a few send_to, kept under the queue limit, + // take the deferred send success arm. + for (int i = 0; i < held; ++i) + { + auto [ec, n] = + co_await s1.send_to(capy::const_buffer("u", 1), ep2); + if (!ec && n == 1) + ++ok; + } + // The preloaded datagrams complete through the deferred + // recv / recv_from success arm. + for (int i = 0; i < held / 2; ++i) + { + auto [ec, n] = + co_await s1.recv(capy::mutable_buffer(buf, sizeof(buf))); + if (!ec && n == 1) + ++ok; + } + for (int i = 0; i < held / 2; ++i) + { + auto [ec, n] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + if (!ec && n == 1) + ++ok; + } + }; + capy::run_async(ex)(driver()); + ioc.run(); + BOOST_TEST_EQ(ok, budget_cycles + 2 * held); + } +#endif // BOOST_COROSIO_POSIX + + void + run() + { + testStreamStoppedOpsDefer(); + testStreamSuccessOpsDefer(); + testUdpStoppedOpsDefer(); + testUdpSuccessOpsDefer(); +#if BOOST_COROSIO_POSIX + testLocalStreamStoppedOpsDefer(); + testLocalStreamSuccessOpsDefer(); + testLocalDatagramStoppedOpsDefer(); + testLocalDatagramSuccessOpsDefer(); +#endif + } +}; + +TEST_SUITE(io_uring_budget_test, "boost.corosio.io_uring_budget"); + +#endif // BOOST_COROSIO_HAS_IO_URING + +} // namespace boost::corosio From 283e06e0742297d5d81525980f0a93952d78bebc Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 18:55:12 +0200 Subject: [PATCH 02/29] test(coverage): claim parked ops on cancel, close, and release --- test/unit/claim_paths.cpp | 469 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 test/unit/claim_paths.cpp diff --git a/test/unit/claim_paths.cpp b/test/unit/claim_paths.cpp new file mode 100644 index 000000000..c06e6413b --- /dev/null +++ b/test/unit/claim_paths.cpp @@ -0,0 +1,469 @@ +// +// 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 +// + +// An op parked in a reactor descriptor slot must be claimed and +// reposted when cancel, close, or release arrives — otherwise it +// would dangle in the slot while its coroutine is torn down. These +// tests park ops on descriptors that will never become ready, then +// drive each claiming entry point from a posted coroutine, so the +// interleaving is deterministic on a single thread. + +#include + +#if BOOST_COROSIO_HAS_EPOLL || BOOST_COROSIO_HAS_KQUEUE || \ + BOOST_COROSIO_HAS_SELECT + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +#include "context.hpp" +#include "test_suite.hpp" + +namespace boost::corosio { + +namespace { + +// Fill the kernel send buffer through the native handle so the next +// write_some parks instead of completing speculatively. +void +fill_send_buffer(native_handle_type fd) +{ + char junk[4096] = {}; + while (::send(fd, junk, sizeof(junk), MSG_DONTWAIT | MSG_NOSIGNAL) > 0) + { + } +} + +} // namespace + +template +struct claim_paths_test +{ + // Parks reader+writer+waiter on s1, then runs `disrupt` from a + // posted coroutine and expects all three to complete canceled. + template + void + checkStreamClaims(Disrupt disrupt) + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + BOOST_TEST_NO_THROW( + s1.set_option(socket_option::send_buffer_size(4096))); + fill_send_buffer(s1.native_handle()); + + char buf[8]; + char big[65536] = {}; + std::error_code rec, wec, wtec; + int done = 0; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + ++done; + }; + auto writer = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.write_some(capy::const_buffer(big, sizeof(big))); + std::ignore = n; + wec = ec; + ++done; + }; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::read); + wtec = ec; + ++done; + }; + auto disruptor = [&]() -> capy::task<> { + disrupt(s1); + co_return; + }; + capy::run_async(ex)(reader()); + capy::run_async(ex)(writer()); + capy::run_async(ex)(waiter()); + capy::run_async(ex)(disruptor()); + ioc.run(); + + BOOST_TEST_EQ(done, 3); + BOOST_TEST(rec == capy::cond::canceled); + BOOST_TEST(wec == capy::cond::canceled); + BOOST_TEST(wtec == capy::cond::canceled); + } + + void + testCancelClaimsParkedOps() + { + checkStreamClaims([](tcp_socket& s) { s.cancel(); }); + } + + void + testCloseClaimsParkedOps() + { + checkStreamClaims([](tcp_socket& s) { s.close(); }); + } + + void + testReleaseClaimsParkedOps() + { + checkStreamClaims([](tcp_socket& s) { + auto fd = s.release(); + if (fd >= 0) + ::close(fd); + }); + } + + void + testStopTokenClaimsParkedRead() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + std::stop_source ss; + char buf[8]; + std::error_code rec; + bool done = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + done = true; + }; + auto stopper = [&]() -> capy::task<> { + ss.request_stop(); + co_return; + }; + capy::run_async(ex, ss.get_token())(reader()); + capy::run_async(ex)(stopper()); + ioc.run(); + + BOOST_TEST(done); + BOOST_TEST(rec == capy::cond::canceled); + } + + template + void + checkAcceptorClaims(Disrupt disrupt) + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST_NO_THROW(acc.set_option(socket_option::reuse_address(true))); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + + tcp_socket peer(ioc); + std::error_code aec, wec; + int done = 0; + auto accepter = [&]() -> capy::task<> { + auto [ec] = co_await acc.accept(peer); + aec = ec; + ++done; + }; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await acc.wait(wait_type::read); + wec = ec; + ++done; + }; + auto disruptor = [&]() -> capy::task<> { + disrupt(acc); + co_return; + }; + capy::run_async(ex)(accepter()); + capy::run_async(ex)(waiter()); + capy::run_async(ex)(disruptor()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(aec == capy::cond::canceled); + BOOST_TEST(wec == capy::cond::canceled); + BOOST_TEST(!peer.is_open()); + } + + void + testAcceptorCancelClaims() + { + checkAcceptorClaims([](tcp_acceptor& a) { a.cancel(); }); + } + + void + testAcceptorCloseClaims() + { + checkAcceptorClaims([](tcp_acceptor& a) { a.close(); }); + } + + void + testAcceptorReleaseClaims() + { + checkAcceptorClaims([](tcp_acceptor& a) { + auto fd = a.release(); + if (fd >= 0) + ::close(fd); + }); + } + + void + testAcceptorStopTokenClaims() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST_NO_THROW(acc.set_option(socket_option::reuse_address(true))); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + + std::stop_source ss; + tcp_socket peer(ioc); + std::error_code aec; + bool done = false; + auto accepter = [&]() -> capy::task<> { + auto [ec] = co_await acc.accept(peer); + aec = ec; + done = true; + }; + auto stopper = [&]() -> capy::task<> { + ss.request_stop(); + co_return; + }; + capy::run_async(ex, ss.get_token())(accepter()); + capy::run_async(ex)(stopper()); + ioc.run(); + + BOOST_TEST(done); + BOOST_TEST(aec == capy::cond::canceled); + } + + void + testAcceptorCloseWhileEnqueued() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + // Both acceptors become readable before the reactor sweeps, so + // both descriptors are enqueued together; whichever accept + // handler dispatches first closes an acceptor that is still + // sitting in the ready queue. + tcp_acceptor a(ioc), b(ioc); + for (tcp_acceptor* acc : {&a, &b}) + { + BOOST_TEST(!acc->open(tcp::v4())); + BOOST_TEST_NO_THROW( + acc->set_option(socket_option::reuse_address(true))); + BOOST_TEST(!acc->bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc->listen()); + } + + tcp_socket pa(ioc), pb(ioc); + std::error_code aec, bec; + int done = 0; + auto accept_a = [&]() -> capy::task<> { + auto [ec] = co_await a.accept(pa); + aec = ec; + ++done; + if (!ec) + b.close(); + }; + auto accept_b = [&]() -> capy::task<> { + auto [ec] = co_await b.accept(pb); + bec = ec; + ++done; + if (!ec) + a.close(); + }; + auto connect_both = [&]() -> capy::task<> { + for (tcp_acceptor* acc : {&a, &b}) + { + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_GE(fd, 0); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_port = htons(acc->local_endpoint().port()); + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::connect( + fd, reinterpret_cast(&sa), sizeof(sa)), 0); + ::close(fd); + } + co_return; + }; + capy::run_async(ex)(accept_a()); + capy::run_async(ex)(accept_b()); + capy::run_async(ex)(connect_both()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + // One accept wins and closes the other; which one is dispatch + // order, so only the outcome set is asserted. + BOOST_TEST((!aec && bec == capy::cond::canceled) || + (!bec && aec == capy::cond::canceled) || (!aec && !bec)); + } + + void + testAcceptorReleaseWhileEnqueued() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + // The udp descriptor registers first, so when one sweep makes + // both descriptors ready, the udp read dispatches first and + // resumes inline; releasing the acceptor there catches its + // descriptor state still enqueued behind the udp one. + udp_socket trigger(ioc); + BOOST_TEST(!trigger.open(udp::v4())); + BOOST_TEST(!trigger.bind(endpoint(ipv4_address::loopback(), 0))); + + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST_NO_THROW(acc.set_option(socket_option::reuse_address(true))); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + + tcp_socket peer(ioc); + char buf[8]; + endpoint src; + std::error_code rec, aec; + int done = 0; + auto releaser = [&]() -> capy::task<> { + auto [ec, n] = co_await trigger.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), src); + std::ignore = n; + rec = ec; + auto fd = acc.release(); + if (fd >= 0) + ::close(fd); + ++done; + }; + auto accepter = [&]() -> capy::task<> { + auto [ec] = co_await acc.accept(peer); + aec = ec; + ++done; + }; + auto trip = [&]() -> capy::task<> { + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + int ufd = ::socket(AF_INET, SOCK_DGRAM, 0); + BOOST_TEST_GE(ufd, 0); + sa.sin_port = htons(trigger.local_endpoint().port()); + BOOST_TEST_EQ(::sendto(ufd, "x", 1, 0, + reinterpret_cast(&sa), sizeof(sa)), 1); + ::close(ufd); + + int tfd = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_GE(tfd, 0); + sa.sin_port = htons(acc.local_endpoint().port()); + BOOST_TEST_EQ(::connect( + tfd, reinterpret_cast(&sa), sizeof(sa)), 0); + ::close(tfd); + co_return; + }; + capy::run_async(ex)(releaser()); + capy::run_async(ex)(accepter()); + capy::run_async(ex)(trip()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(!rec); + // Dispatch order decides whether the accept was still parked + // when the release landed. + BOOST_TEST(aec == capy::cond::canceled || !aec); + } + + void + testDatagramCloseClaims() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + udp_socket s1(ioc); + BOOST_TEST(!s1.open(udp::v4())); + BOOST_TEST(!s1.bind(endpoint(ipv4_address::loopback(), 0))); + + char buf[8]; + endpoint source; + std::error_code rec, wtec; + int done = 0; + auto receiver = [&]() -> capy::task<> { + auto [ec, n] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + std::ignore = n; + rec = ec; + ++done; + }; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::read); + wtec = ec; + ++done; + }; + auto closer = [&]() -> capy::task<> { + s1.close(); + co_return; + }; + capy::run_async(ex)(receiver()); + capy::run_async(ex)(waiter()); + capy::run_async(ex)(closer()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(rec == capy::cond::canceled); + BOOST_TEST(wtec == capy::cond::canceled); + } + + void + run() + { + testCancelClaimsParkedOps(); + testCloseClaimsParkedOps(); + testReleaseClaimsParkedOps(); + testStopTokenClaimsParkedRead(); + testAcceptorCancelClaims(); + testAcceptorCloseClaims(); + testAcceptorReleaseClaims(); + testAcceptorStopTokenClaims(); + testAcceptorCloseWhileEnqueued(); + testAcceptorReleaseWhileEnqueued(); + testDatagramCloseClaims(); + } +}; + +COROSIO_REACTOR_BACKEND_TESTS(claim_paths_test, "boost.corosio.claim_paths") + +} // namespace boost::corosio + +#endif From 366eee4dc452979390d7fc7ba85d6d438d9718f4 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 18:56:13 +0200 Subject: [PATCH 03/29] test(coverage): complete file and resolver ops inline when the pool refuses --- test/unit/random_access_file.cpp | 41 ++++++++++++++++++++++++++++++++ test/unit/resolver.cpp | 40 +++++++++++++++++++++++++++++++ test/unit/stream_file.cpp | 41 ++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/test/unit/random_access_file.cpp b/test/unit/random_access_file.cpp index 9f572d077..f75c11fe5 100644 --- a/test/unit/random_access_file.cpp +++ b/test/unit/random_access_file.cpp @@ -673,6 +673,46 @@ struct random_access_file_test BOOST_TEST(!resumed); } + // The pool refuses work once it has shut down; the service must + // complete the op inline with the refusal instead of parking it. + void testReadWriteAtAfterPoolShutdown() + { +#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_shut_", "hello world"); + io_context ioc(Backend); + auto ex = ioc.get_executor(); + random_access_file f(ioc); + BOOST_TEST(!f.open(tmp.path, file_base::read_write)); + ioc.use_service().shutdown(); + + std::error_code rec, wec; + int done = 0; + auto driver = [&]() -> capy::task<> { + char buf[16]; + auto [r, rn] = co_await f.read_some_at( + 0, capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = rn; + rec = r; + ++done; + auto [w, wn] = + co_await f.write_some_at(0, capy::const_buffer("x", 1)); + std::ignore = wn; + wec = w; + ++done; + }; + capy::run_async(ex)(driver()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(rec == capy::cond::canceled); + BOOST_TEST(wec == capy::cond::canceled); + } + // 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 @@ -772,6 +812,7 @@ struct random_access_file_test #if BOOST_COROSIO_POSIX // POSIX file work runs on the pool; IOCP uses overlapped I/O. testDestroyWithPoolWorkQueued(); + testReadWriteAtAfterPoolShutdown(); #endif #if !COROSIO_TEST_HAS_ASAN diff --git a/test/unit/resolver.cpp b/test/unit/resolver.cpp index bafcb80da..15c400ab3 100644 --- a/test/unit/resolver.cpp +++ b/test/unit/resolver.cpp @@ -1227,6 +1227,43 @@ struct resolver_test } #endif +#if BOOST_COROSIO_POSIX + // The pool refuses work once it has shut down; the resolver must + // complete both directions inline with the refusal. + void testResolveAfterPoolShutdown() + { + io_context ioc; + auto ex = ioc.get_executor(); + resolver r(ioc); + ioc.use_service().shutdown(); + + std::error_code fec, rec; + int done = 0; + auto driver = [&]() -> capy::task<> { + { + auto [ec, res] = co_await r.resolve("localhost", "80"); + std::ignore = res; + fec = ec; + ++done; + } + { + auto [ec, res] = + co_await r.resolve(endpoint(ipv4_address::loopback(), 80)); + std::ignore = res; + rec = ec; + ++done; + } + }; + capy::run_async(ex)(driver()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(fec == capy::cond::canceled); + BOOST_TEST(rec == capy::cond::canceled); + } +#endif + + void run() { // Construction and move semantics @@ -1258,6 +1295,9 @@ struct resolver_test // Cancellation testCancel(); testCancelNoOperation(); +#if BOOST_COROSIO_POSIX + testResolveAfterPoolShutdown(); +#endif testResolveStopTokenCancellation(); testReverseResolveStopTokenCancellation(); diff --git a/test/unit/stream_file.cpp b/test/unit/stream_file.cpp index fbdbd5919..1b9491cb7 100644 --- a/test/unit/stream_file.cpp +++ b/test/unit/stream_file.cpp @@ -975,6 +975,46 @@ struct stream_file_test BOOST_TEST(!resumed); } + // The pool refuses work once it has shut down; the service must + // complete the op inline with the refusal instead of parking it. + void testReadWriteAfterPoolShutdown() + { +#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_shut_", "hello world"); + io_context ioc(Backend); + auto ex = ioc.get_executor(); + stream_file f(ioc); + BOOST_TEST(!f.open(tmp.path, file_base::read_write)); + ioc.use_service().shutdown(); + + std::error_code rec, wec; + int done = 0; + auto driver = [&]() -> capy::task<> { + char buf[16]; + auto [r, rn] = + co_await f.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = rn; + rec = r; + ++done; + auto [w, wn] = co_await f.write_some(capy::const_buffer("x", 1)); + std::ignore = wn; + wec = w; + ++done; + }; + capy::run_async(ex)(driver()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(rec == capy::cond::canceled); + BOOST_TEST(wec == capy::cond::canceled); + } + + // 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 @@ -1071,6 +1111,7 @@ struct stream_file_test #if BOOST_COROSIO_POSIX // POSIX file work runs on the pool; IOCP uses overlapped I/O. testDestroyWithPoolWorkQueued(); + testReadWriteAfterPoolShutdown(); #endif #if !COROSIO_TEST_HAS_ASAN From 25f2fb4f345f3a7cfb3f2ad334652298817a7b70 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 19:07:13 +0200 Subject: [PATCH 04/29] test(tls): reject bad credentials, ciphers, and CRLs at handshake --- test/unit/openssl_stream.cpp | 92 ++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/test/unit/openssl_stream.cpp b/test/unit/openssl_stream.cpp index b06d6c9ce..b2c62da73 100644 --- a/test/unit/openssl_stream.cpp +++ b/test/unit/openssl_stream.cpp @@ -166,6 +166,92 @@ struct openssl_stream_test std::filesystem::remove_all(dir); } + // One handshake attempt over a mocket pair against a server + // context configured with bad material; the deferred native-context + // build must surface the rejection from the handshake. + bool serverHandshakeFails(tls_context const& server_ctx) + { + io_context ioc; + auto [m1, m2] = corosio::test::make_mocket_pair(ioc); + + auto client_ctx = test::make_client_context(); + auto client = make_stream(m1, client_ctx); + auto server = make_stream(m2, server_ctx); + + bool client_done = false, server_done = false; + std::error_code client_ec, server_ec; + auto client_hs = [&]() -> capy::task<> { + auto [ec] = co_await client.handshake(tls_role::client); + client_ec = ec; + client_done = true; + m1.close(); + }; + auto server_hs = [&]() -> capy::task<> { + auto [ec] = co_await server.handshake(tls_role::server); + server_ec = ec; + server_done = true; + m2.close(); + }; + capy::run_async(ioc.get_executor())(client_hs()); + capy::run_async(ioc.get_executor())(server_hs()); + ioc.run(); + + BOOST_TEST(client_done); + BOOST_TEST(server_done); + return !!client_ec || !!server_ec; + } + + void testGarbagePkcs12FailsHandshake() + { + tls_context ctx; + test::require_ok(ctx.use_pkcs12("not-pkcs12-data", "password")); + test::require_ok(ctx.set_verify_mode(tls_verify_mode::none)); + BOOST_TEST(serverHandshakeFails(ctx)); + } + + void testGarbageCaFailsHandshake() + { + auto ctx = test::make_server_context(); + test::require_ok(ctx.add_certificate_authority( + "-----BEGIN JUNK-----\nnope\n-----END JUNK-----\n")); + BOOST_TEST(serverHandshakeFails(ctx)); + } + + void testBadCipherListFailsHandshake() + { + auto ctx = test::make_server_context(); + test::require_ok(ctx.set_ciphersuites("NOT-A-CIPHER")); + BOOST_TEST(serverHandshakeFails(ctx)); + } + + void testBadTls13SuitesFailsHandshake() + { + auto ctx = test::make_server_context(); + test::require_ok(ctx.set_ciphersuites_tls13("garbage")); + BOOST_TEST(serverHandshakeFails(ctx)); + } + + void testBadCrlWithRevocationFailsHandshake() + { + auto ctx = test::make_server_context(); + test::require_ok(ctx.add_crl("not a crl")); + ctx.set_revocation_policy(tls_revocation_policy::soft_fail); + BOOST_TEST(serverHandshakeFails(ctx)); + } + + void testDuplicateCaTolerated() + { + io_context ioc; + auto client_ctx = test::make_client_context(); + // The store already holds this CA; the duplicate must be + // tolerated, not fail the whole context build. + test::require_ok( + client_ctx.add_certificate_authority(test::ca_cert_pem)); + auto server_ctx = test::make_server_context(); + test::run_tls_test(ioc, client_ctx, server_ctx, make_stream, + make_stream); + } + void run() { test::testIoBeforeHandshake(make_stream); @@ -216,6 +302,12 @@ struct openssl_stream_test test::testAbruptClose(make_stream); test::testEncryptedKey(make_stream); test::testInvalidContextHandshake(make_stream); + testGarbagePkcs12FailsHandshake(); + testGarbageCaFailsHandshake(); + testBadCipherListFailsHandshake(); + testBadTls13SuitesFailsHandshake(); + testBadCrlWithRevocationFailsHandshake(); + testDuplicateCaTolerated(); test::testReset(make_stream, cert_modes); test::testResetViaHandshake(make_stream, cert_modes); From 343475a941e1a577b4126a545c070a19157a7c30 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 19:07:14 +0200 Subject: [PATCH 05/29] test(signals): cover multi-registration removal and queued delivery --- test/unit/signal_set.cpp | 132 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/test/unit/signal_set.cpp b/test/unit/signal_set.cpp index 46af57705..f3069046b 100644 --- a/test/unit/signal_set.cpp +++ b/test/unit/signal_set.cpp @@ -803,11 +803,136 @@ struct signal_set_test BOOST_TEST(!result); } + void testRemoveOneOfTwoSignals() + { + io_context ioc(Backend); + signal_set s(ioc); + + BOOST_TEST(!s.add(SIGINT)); + BOOST_TEST(!s.add(SIGTERM)); + // Removing the higher signal number walks the sorted per-set + // list past the lower one. + BOOST_TEST(!s.remove(SIGTERM)); + BOOST_TEST(!s.remove(SIGINT)); + } + + void testTwoSetsSameSignalRemoveInBothOrders() + { + io_context ioc(Backend); + { + signal_set s1(ioc), s2(ioc); + BOOST_TEST(!s1.add(SIGINT)); + BOOST_TEST(!s2.add(SIGINT)); + BOOST_TEST(!s1.remove(SIGINT)); + BOOST_TEST(!s2.remove(SIGINT)); + } + { + signal_set s1(ioc), s2(ioc); + BOOST_TEST(!s1.add(SIGINT)); + BOOST_TEST(!s2.add(SIGINT)); + BOOST_TEST(!s2.remove(SIGINT)); + BOOST_TEST(!s1.remove(SIGINT)); + } + { + // clear() walks the same per-signal table links. + signal_set s1(ioc), s2(ioc); + BOOST_TEST(!s1.add(SIGINT)); + BOOST_TEST(!s2.add(SIGINT)); + BOOST_TEST(!s1.clear()); + BOOST_TEST(!s2.clear()); + } + } + + void testSignalDeliveredBeforeWait() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + // Two sets on one signal, only one waiting: delivery posts to + // the waiter and queues on the idle registration, whose later + // wait must consume the queued signal immediately. + signal_set s1(ioc), s2(ioc); + BOOST_TEST(!s1.add(SIGINT)); + BOOST_TEST(!s2.add(SIGINT)); + + int got1 = 0; + auto wait1 = [&]() -> capy::task<> { + auto [ec, sig] = co_await s1.wait(); + if (!ec) + got1 = sig; + }; + capy::run_async(ex)(wait1()); + std::raise(SIGINT); + ioc.run(); + ioc.restart(); + BOOST_TEST_EQ(got1, SIGINT); + + int got2 = 0; + auto wait2 = [&]() -> capy::task<> { + auto [ec, sig] = co_await s2.wait(); + if (!ec) + got2 = sig; + }; + capy::run_async(ex)(wait2()); + ioc.run(); + + BOOST_TEST_EQ(got2, SIGINT); + } + + void testTwoServicesDestroyInBothOrders() + { + // Two io_contexts give the process-wide service list two + // entries; destroying them in each order exercises both + // unlink shapes. Each set dies before its own context. + { + std::optional a(std::in_place, Backend); + std::optional b(std::in_place, Backend); + std::optional sa(std::in_place, *a); + std::optional sb(std::in_place, *b); + BOOST_TEST(!sa->add(SIGINT)); + BOOST_TEST(!sb->add(SIGINT)); + sa.reset(); + a.reset(); + sb.reset(); + b.reset(); + } + { + std::optional a(std::in_place, Backend); + std::optional b(std::in_place, Backend); + std::optional sa(std::in_place, *a); + std::optional sb(std::in_place, *b); + BOOST_TEST(!sa->add(SIGINT)); + BOOST_TEST(!sb->add(SIGINT)); + sb.reset(); + b.reset(); + sa.reset(); + a.reset(); + } + } + + #if BOOST_COROSIO_POSIX // Signal flags tests (POSIX only) // Windows returns operation_not_supported for // flags other than none/dont_care + void testAddWithChildAndResetFlags() + { + io_context ioc(Backend); + signal_set s(ioc); + + // Never raised here: only the sigaction flag translation is + // under test. + BOOST_TEST(!s.add(SIGCHLD, + signal_set::no_child_stop | signal_set::no_child_wait)); + BOOST_TEST(!s.remove(SIGCHLD)); + + signal_set r(ioc); + BOOST_TEST(!r.add(SIGWINCH, signal_set::reset_handler)); + BOOST_TEST(!r.remove(SIGWINCH)); + } + + void testAddWithFlags() { io_context ioc(Backend); @@ -996,6 +1121,12 @@ struct signal_set_test // Queued signal tests testQueuedSignal(); + testRemoveOneOfTwoSignals(); + testTwoSetsSameSignalRemoveInBothOrders(); + testSignalDeliveredBeforeWait(); + testTwoServicesDestroyInBothOrders(); + + // Registration list surgery // Sequential wait tests testSequentialWaits(); @@ -1017,6 +1148,7 @@ struct signal_set_test #if BOOST_COROSIO_POSIX // Signal flags tests (POSIX only) testAddWithFlags(); + testAddWithChildAndResetFlags(); testAddWithMultipleFlags(); testAddSameSignalSameFlags(); testAddSameSignalDifferentFlags(); From a8aefac5a2a278534e077025b9ac64c17e05f30c Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 19:16:02 +0200 Subject: [PATCH 06/29] test(ipv6): cover tail-validation rejection arms and to_buffer bounds --- test/unit/ipv6_address.cpp | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/unit/ipv6_address.cpp b/test/unit/ipv6_address.cpp index 00507c9e9..a266eba34 100644 --- a/test/unit/ipv6_address.cpp +++ b/test/unit/ipv6_address.cpp @@ -286,6 +286,45 @@ struct ipv6_address_test BOOST_TEST_EQ(oss.str(), "::1"); } + void testParseRejectionArms() + { + // Each string lands on a distinct rejection arm of the + // IPv4-in-IPv6 tail validation. + auto check_invalid = [](std::string_view sv) { + auto [ec, addr] = make_ipv6_address(sv); + BOOST_TEST(ec == std::errc::invalid_argument); + BOOST_TEST(addr == ipv6_address()); + }; + // decimal reinterpretation exceeds 255 + check_invalid("::256.0.0.0"); + // middle hex nibble is not a decimal digit + check_invalid("::1a1.2.3.4"); + // low hex nibble is not a decimal digit + check_invalid("::10a.2.3.4"); + // h16 expected at end of input + check_invalid("::1:"); + check_invalid("1:2:3:4:5:6:7:"); + // IPv4 tail cut short + check_invalid("::1.2.3."); + check_invalid("::1.2.3.4.5"); + } + + void testToBufferBoundaries() + { + char exact[ipv6_address::max_str_len]; + auto sv = ipv6_address::loopback().to_buffer(exact, sizeof(exact)); + BOOST_TEST_EQ(sv, "::1"); + + char short_by_one[ipv6_address::max_str_len - 1]; + BOOST_TEST_THROWS( + ipv6_address::loopback().to_buffer( + short_by_one, sizeof(short_by_one)), + std::length_error); + BOOST_TEST_THROWS( + ipv6_address::loopback().to_buffer(exact, 0), std::length_error); + } + + void run() { testConstruction(); @@ -293,6 +332,8 @@ struct ipv6_address_test testParseEndsWithDoubleColon(); testParseInvalidIPv4Suffix(); testParseMoreEdges(); + testParseRejectionArms(); + testToBufferBoundaries(); testToString(); testToStringHexWidths(); testToBuffer(); From 0468d6373acc0b15d80aca8f2fda9845063de383 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 19:16:03 +0200 Subject: [PATCH 07/29] test(sockets): cover datagram edge ops and assign validation --- test/unit/local_datagram_socket.cpp | 55 ++++++++++ test/unit/local_stream_socket.cpp | 37 +++++++ test/unit/udp_socket.cpp | 161 ++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) diff --git a/test/unit/local_datagram_socket.cpp b/test/unit/local_datagram_socket.cpp index f19780426..0af0b0635 100644 --- a/test/unit/local_datagram_socket.cpp +++ b/test/unit/local_datagram_socket.cpp @@ -923,8 +923,63 @@ struct local_datagram_socket_test BOOST_TEST_EQ(resumed, before_destroy); } + void testSendToMissingPathReportsError() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + test::temp_socket_dir tmp; + + local_datagram_socket d(ioc); + BOOST_TEST(!d.open()); + + std::error_code sec; + auto task = [&]() -> capy::task<> { + auto [ec, n] = co_await d.send_to( + capy::const_buffer("x", 1), local_endpoint(tmp.path())); + std::ignore = n; + sec = ec; + }; + capy::run_async(ex)(task()); + ioc.run(); + BOOST_TEST(!!sec); + } + + + void testWaitWriteReady() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + local_datagram_socket d1(ioc), d2(ioc); + BOOST_TEST(!connect_pair(d1, d2)); + + std::error_code wec = std::make_error_code(std::errc::io_error); + auto task = [&]() -> capy::task<> { + auto [ec] = co_await d1.wait(wait_type::write); + wec = ec; + }; + capy::run_async(ex)(task()); + ioc.run(); + BOOST_TEST(!wec); + } + + void testAssignSelfRejected() + { + io_context ioc(Backend); + local_datagram_socket d(ioc); + BOOST_TEST(!d.open()); + BOOST_TEST( + d.assign(d.native_handle()) == + std::make_error_code(std::errc::invalid_argument)); + BOOST_TEST(d.is_open()); + } + + void run() { + testSendToMissingPathReportsError(); + testWaitWriteReady(); + testAssignSelfRejected(); + testConstruction(); testOpen(); testMove(); diff --git a/test/unit/local_stream_socket.cpp b/test/unit/local_stream_socket.cpp index 29eabf271..cc6d70ee6 100644 --- a/test/unit/local_stream_socket.cpp +++ b/test/unit/local_stream_socket.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -1757,8 +1758,44 @@ struct local_stream_socket_test } #endif + void testAssignSelfRejected() + { + io_context ioc(Backend); + local_stream_socket s(ioc); + BOOST_TEST(!s.open()); + BOOST_TEST( + s.assign(s.native_handle()) == + std::make_error_code(std::errc::invalid_argument)); + BOOST_TEST(s.is_open()); + } + + void testAcceptorAssignSelfAndWrongType() + { + io_context ioc(Backend); + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST( + acc.assign(acc.native_handle()) == + std::make_error_code(std::errc::invalid_argument)); + BOOST_TEST(acc.is_open()); + +#if BOOST_COROSIO_POSIX + // A datagram fd is not a listenable stream socket. Windows + // AF_UNIX has no datagram sockets, so the probe is POSIX-only. + local_datagram_socket d(ioc); + BOOST_TEST(!d.open()); + auto dfd = d.release(); + BOOST_TEST(!!acc.assign(dfd)); + ::close(dfd); +#endif + } + + void run() { + testAssignSelfRejected(); + testAcceptorAssignSelfAndWrongType(); + testConstruction(); testOpen(); testMove(); diff --git a/test/unit/udp_socket.cpp b/test/unit/udp_socket.cpp index 691ac1e8c..db65fc3ec 100644 --- a/test/unit/udp_socket.cpp +++ b/test/unit/udp_socket.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #if BOOST_COROSIO_POSIX @@ -1698,8 +1699,168 @@ struct udp_socket_test BOOST_TEST_EQ(resumed, before_destroy); } + void testRecvReportsIcmpRefusal() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + udp_socket probe(ioc), s(ioc); + BOOST_TEST(!probe.open(udp::v4())); + BOOST_TEST(!probe.bind(endpoint(ipv4_address::loopback(), 0))); + auto dead = probe.local_endpoint(); + probe.close(); + + BOOST_TEST(!s.open(udp::v4())); + std::error_code cec, sec; + auto setup = [&]() -> capy::task<> { + auto [c] = co_await s.connect(dead); + cec = c; + auto [e, n] = co_await s.send(capy::const_buffer("x", 1)); + std::ignore = n; + sec = e; + }; + capy::run_async(ex)(setup()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!cec); + + // Loopback queues the port-unreachable error before the recv + // initiates; if a platform does not, the canceller keeps the + // test bounded and the recv still completes with an error. + char buf[8]; + std::error_code rec; + auto receiver = [&]() -> capy::task<> { + auto [ec, n] = + co_await s.recv(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + }; + auto canceller = [&]() -> capy::task<> { + s.cancel(); + co_return; + }; + capy::run_async(ex)(receiver()); + capy::run_async(ex)(canceller()); + ioc.run(); + BOOST_TEST(!!rec); + } + + + void testConnectedShutdownSendSucceeds() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + udp_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open(udp::v4())); + BOOST_TEST(!s2.open(udp::v4())); + BOOST_TEST(!s1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!s2.bind(endpoint(ipv4_address::loopback(), 0))); + + bool ok = false; + auto task = [&]() -> capy::task<> { + auto [cec] = co_await s1.connect(s2.local_endpoint()); + if (!cec && !s1.shutdown(shutdown_send)) + ok = true; + }; + capy::run_async(ex)(task()); + ioc.run(); + BOOST_TEST(ok); + } + + void testWaitWriteReady() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + udp_socket s(ioc); + BOOST_TEST(!s.open(udp::v4())); + BOOST_TEST(!s.bind(endpoint(ipv4_address::loopback(), 0))); + + std::error_code wec = std::make_error_code(std::errc::io_error); + auto task = [&]() -> capy::task<> { + auto [ec] = co_await s.wait(wait_type::write); + wec = ec; + }; + capy::run_async(ex)(task()); + ioc.run(); + BOOST_TEST(!wec); + } + + void testOversizedSendReportsError() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + udp_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open(udp::v4())); + BOOST_TEST(!s2.open(udp::v4())); + BOOST_TEST(!s1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!s2.bind(endpoint(ipv4_address::loopback(), 0))); + + // Larger than any UDP datagram can be. + std::vector big(70000, 'x'); + std::error_code sec; + auto task = [&]() -> capy::task<> { + auto [ec, n] = co_await s1.send_to( + capy::const_buffer(big.data(), big.size()), + s2.local_endpoint()); + std::ignore = n; + sec = ec; + }; + capy::run_async(ex)(task()); + ioc.run(); +#if BOOST_COROSIO_POSIX + BOOST_TEST(sec == std::errc::message_size); +#else + // Which WSA codes a toolchain's system_category maps to errc + // conditions differs between MSVC and MinGW. + BOOST_TEST(!!sec); +#endif + } + + void testAssignSelfRejected() + { + io_context ioc(Backend); + udp_socket s(ioc); + BOOST_TEST(!s.open(udp::v4())); + BOOST_TEST( + s.assign(s.native_handle()) == + std::make_error_code(std::errc::invalid_argument)); + BOOST_TEST(s.is_open()); + } + + void testAssignConnectedFdCachesRemote() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + udp_socket s1(ioc), s2(ioc), s3(ioc); + BOOST_TEST(!s1.open(udp::v4())); + BOOST_TEST(!s2.open(udp::v4())); + BOOST_TEST(!s1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!s2.bind(endpoint(ipv4_address::loopback(), 0))); + auto target = s1.local_endpoint(); + + bool ok = false; + auto task = [&]() -> capy::task<> { + auto [cec] = co_await s2.connect(target); + if (cec) + co_return; + auto fd = s2.release(); + if (!s3.assign(fd) && s3.remote_endpoint() == target) + ok = true; + }; + capy::run_async(ex)(task()); + ioc.run(); + BOOST_TEST(ok); + } + + void run() { + testRecvReportsIcmpRefusal(); + testConnectedShutdownSendSucceeds(); + testWaitWriteReady(); + testOversizedSendReportsError(); + testAssignSelfRejected(); + testAssignConnectedFdCachesRemote(); + testConstruction(); testOpen(); testOpenV6(); From b57b750444fd64958dae83e341cb204a9bb735ce Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 19:16:04 +0200 Subject: [PATCH 08/29] test(server): hand a dropped launcher's worker to a waiting accept --- test/unit/tcp_server.cpp | 91 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/test/unit/tcp_server.cpp b/test/unit/tcp_server.cpp index e4ed40c7f..aaaf8f89e 100644 --- a/test/unit/tcp_server.cpp +++ b/test/unit/tcp_server.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include "context.hpp" @@ -803,8 +804,98 @@ struct tcp_server_test BOOST_TEST_EQ(echoed.load(), 2); } + void testLauncherDropWakesWaitingAccept() + { + // A worker parks its launcher, so the pool is empty when the + // next connection arrives and the accept loop waits for a + // worker. Destroying the parked launcher must hand the worker + // straight to that waiter. + io_context ioc(Backend); + + class parking_worker : public tcp_server::worker_base + { + corosio::tcp_socket sock_; + + public: + std::optional* slot = nullptr; + std::atomic* run_count = nullptr; + + parking_worker(io_context& ctx, + std::optional* s, std::atomic* c) + : sock_(ctx), slot(s), run_count(c) + { + } + + corosio::tcp_socket& socket() override { return sock_; } + + void run(tcp_server::launcher launch) override + { + sock_.close(); + if (run_count->fetch_add(1) == 0) + slot->emplace(std::move(launch)); + } + }; + + std::optional parked; + std::atomic run_count{0}; + + class parking_server : public tcp_server + { + public: + parking_server(io_context& ctx, + std::optional* s, std::atomic* c) + : tcp_server(ctx, ctx.get_executor()) + { + std::vector> v; + v.push_back(std::make_unique(ctx, s, c)); + set_workers(std::move(v)); + } + }; + + parking_server srv(ioc, &parked, &run_count); + auto ec = srv.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + auto port = srv.local_endpoint().port(); + + srv.start(); + + auto driver = [](io_context* ioc, std::uint16_t port, + parking_server* srv, + std::optional* parked) + -> capy::task<> { + tcp_socket c1(*ioc); + BOOST_TEST(!c1.open()); + [[maybe_unused]] auto [e1] = co_await c1.connect( + endpoint(ipv4_address::loopback(), port)); + std::ignore = co_await corosio::delay(std::chrono::milliseconds(20)); + + // Pool is now empty; this connection parks the accept loop. + tcp_socket c2(*ioc); + BOOST_TEST(!c2.open()); + [[maybe_unused]] auto [e2] = co_await c2.connect( + endpoint(ipv4_address::loopback(), port)); + std::ignore = co_await corosio::delay(std::chrono::milliseconds(20)); + + // Dropping the parked launcher wakes the waiting accept. + parked->reset(); + std::ignore = co_await corosio::delay(std::chrono::milliseconds(20)); + + c1.close(); + c2.close(); + srv->stop(); + }(&ioc, port, &srv, &parked); + + capy::run_async(ioc.get_executor())(std::move(driver)); + ioc.run(); + srv.join(); + + BOOST_TEST_EQ(run_count.load(), 2); + } + + void run() { + testLauncherDropWakesWaitingAccept(); testStopServer(); testStopWithActiveConnection(); testStartIdempotent(); From 5ebb985496b8f5f098ce624d782240ee1c5b235a Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 19:20:50 +0200 Subject: [PATCH 09/29] test(native): observe stop requests at resume time --- test/unit/native/native_resume_cancel.cpp | 352 ++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 test/unit/native/native_resume_cancel.cpp diff --git a/test/unit/native/native_resume_cancel.cpp b/test/unit/native/native_resume_cancel.cpp new file mode 100644 index 000000000..878b37be8 --- /dev/null +++ b/test/unit/native/native_resume_cancel.cpp @@ -0,0 +1,352 @@ +// +// 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 native awaitables re-check the stop token at resume time and +// report cancellation even when the operation itself completed. No +// other suite drives the native fronts with a stopped token, so these +// resume-time arms are exercised here: once with a pre-stopped token, +// and once with the stop requested after data is already buffered so +// the op genuinely succeeds before the resume-time check overrides it. + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#if BOOST_COROSIO_POSIX +#include +#include +#include +#include +#include + +#include +#endif + +#include "context.hpp" +#include "test_suite.hpp" + +namespace boost::corosio { + +template +struct native_resume_cancel_test +{ + void testTcpPreStopped() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = test::make_socket_pair< + native_tcp_socket, + native_tcp_acceptor>(ioc); + auto peer = s1.remote_endpoint(); + + std::stop_source ss; + ss.request_stop(); + + char buf[8]; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + auto [rec, rn] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + if (rec == capy::cond::canceled && rn == 0) + ++canceled; + auto [wec, wn] = co_await s1.write_some(capy::const_buffer("x", 1)); + if (wec == capy::cond::canceled && wn == 0) + ++canceled; + auto [cec] = co_await s1.connect(peer); + if (cec == capy::cond::canceled) + ++canceled; + auto [tec] = co_await s1.wait(wait_type::read); + if (tec == capy::cond::canceled) + ++canceled; + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 4); + } + + void testTcpStopAfterDataBuffered() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = test::make_socket_pair< + native_tcp_socket, + native_tcp_acceptor>(ioc); + + std::error_code pec; + auto preload = [&]() -> capy::task<> { + auto [ec, n] = co_await s2.write_some(capy::const_buffer("hi", 2)); + std::ignore = n; + pec = ec; + }; + capy::run_async(ex)(preload()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!pec); + + std::stop_source ss; + char buf[8]; + std::error_code rec; + std::size_t rn = 99; + auto reader = [&]() -> capy::task<> { + ss.request_stop(); + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + rec = ec; + rn = n; + }; + capy::run_async(ex, ss.get_token())(reader()); + ioc.run(); + + BOOST_TEST(rec == capy::cond::canceled); + BOOST_TEST_EQ(rn, 0u); + } + + void testTcpAcceptorPreStopped() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + native_tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + + std::stop_source ss; + ss.request_stop(); + + native_tcp_socket peer(ioc); + int canceled = 0; + auto driver = [&]() -> capy::task<> { + auto [aec] = co_await acc.accept(peer); + if (aec == capy::cond::canceled) + ++canceled; + auto [wec] = co_await acc.wait(wait_type::read); + if (wec == capy::cond::canceled) + ++canceled; + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 2); + BOOST_TEST(!peer.is_open()); + } + + void testUdpPreStopped() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + native_udp_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open(udp::v4())); + BOOST_TEST(!s2.open(udp::v4())); + BOOST_TEST(!s1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!s2.bind(endpoint(ipv4_address::loopback(), 0))); + auto peer = s2.local_endpoint(); + + std::error_code cec0; + auto connecter = [&]() -> capy::task<> { + auto [ec] = co_await s1.connect(peer); + cec0 = ec; + }; + capy::run_async(ex)(connecter()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!cec0); + + std::stop_source ss; + ss.request_stop(); + + char buf[8]; + endpoint source; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + auto [aec, an] = co_await s1.send(capy::const_buffer("x", 1)); + if (aec == capy::cond::canceled && an == 0) + ++canceled; + auto [bec, bn] = + co_await s1.recv(capy::mutable_buffer(buf, sizeof(buf))); + if (bec == capy::cond::canceled && bn == 0) + ++canceled; + auto [dec, dn] = + co_await s1.send_to(capy::const_buffer("x", 1), peer); + if (dec == capy::cond::canceled && dn == 0) + ++canceled; + auto [eec, en] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + if (eec == capy::cond::canceled && en == 0) + ++canceled; + auto [fec] = co_await s1.connect(peer); + if (fec == capy::cond::canceled) + ++canceled; + auto [gec] = co_await s1.wait(wait_type::read); + if (gec == capy::cond::canceled) + ++canceled; + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 6); + } + +#if BOOST_COROSIO_POSIX + void testLocalStreamPreStopped() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + native_local_stream_socket a(ioc), b(ioc); + if (auto ec = connect_pair(a, b)) + throw std::system_error(ec, "connect_pair"); + + test::temp_socket_dir tmp; + auto target = local_endpoint(tmp.path()); + + std::stop_source ss; + ss.request_stop(); + + char buf[8]; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + auto [rec, rn] = + co_await a.read_some(capy::mutable_buffer(buf, sizeof(buf))); + if (rec == capy::cond::canceled && rn == 0) + ++canceled; + auto [wec, wn] = co_await a.write_some(capy::const_buffer("x", 1)); + if (wec == capy::cond::canceled && wn == 0) + ++canceled; + auto [cec] = co_await a.connect(target); + if (cec == capy::cond::canceled) + ++canceled; + auto [tec] = co_await a.wait(wait_type::read); + if (tec == capy::cond::canceled) + ++canceled; + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 4); + } + + void testLocalStreamAcceptorPreStopped() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + test::temp_socket_dir tmp; + native_local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(local_endpoint(tmp.path()))); + BOOST_TEST(!acc.listen()); + + std::stop_source ss; + ss.request_stop(); + + native_local_stream_socket peer(ioc); + int canceled = 0; + auto driver = [&]() -> capy::task<> { + auto [aec] = co_await acc.accept(peer); + if (aec == capy::cond::canceled) + ++canceled; + auto [wec] = co_await acc.wait(wait_type::read); + if (wec == capy::cond::canceled) + ++canceled; + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 2); + BOOST_TEST(!peer.is_open()); + } + + void testLocalDatagramPreStopped() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + test::temp_socket_dir tmp1; + test::temp_socket_dir tmp2; + native_local_datagram_socket s1(ioc), s2(ioc); + BOOST_TEST(!s1.open()); + BOOST_TEST(!s2.open()); + BOOST_TEST(!s1.bind(local_endpoint(tmp1.path()))); + BOOST_TEST(!s2.bind(local_endpoint(tmp2.path()))); + auto peer = local_endpoint(tmp2.path()); + + std::error_code cec0; + auto connecter = [&]() -> capy::task<> { + auto [ec] = co_await s1.connect(peer); + cec0 = ec; + }; + capy::run_async(ex)(connecter()); + ioc.run(); + ioc.restart(); + BOOST_TEST(!cec0); + + std::stop_source ss; + ss.request_stop(); + + char buf[8]; + local_endpoint source; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + auto [aec, an] = co_await s1.send(capy::const_buffer("x", 1)); + if (aec == capy::cond::canceled && an == 0) + ++canceled; + auto [bec, bn] = + co_await s1.recv(capy::mutable_buffer(buf, sizeof(buf))); + if (bec == capy::cond::canceled && bn == 0) + ++canceled; + auto [dec, dn] = + co_await s1.send_to(capy::const_buffer("x", 1), peer); + if (dec == capy::cond::canceled && dn == 0) + ++canceled; + auto [eec, en] = co_await s1.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), source); + if (eec == capy::cond::canceled && en == 0) + ++canceled; + auto [fec] = co_await s1.connect(peer); + if (fec == capy::cond::canceled) + ++canceled; + auto [gec] = co_await s1.wait(wait_type::read); + if (gec == capy::cond::canceled) + ++canceled; + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 6); + } +#endif // BOOST_COROSIO_POSIX + + void run() + { + testTcpPreStopped(); + testTcpStopAfterDataBuffered(); + testTcpAcceptorPreStopped(); + testUdpPreStopped(); +#if BOOST_COROSIO_POSIX + testLocalStreamPreStopped(); + testLocalStreamAcceptorPreStopped(); + testLocalDatagramPreStopped(); +#endif + } +}; + +COROSIO_BACKEND_TESTS(native_resume_cancel_test, "boost.corosio.native.resume_cancel") + +} // namespace boost::corosio From fa76f928e9187092c923a98dc39b229a4a9ea397 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 19:20:51 +0200 Subject: [PATCH 10/29] test(coverage): bounded run variants on reactor schedulers --- test/unit/bounded_run.cpp | 249 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 test/unit/bounded_run.cpp diff --git a/test/unit/bounded_run.cpp b/test/unit/bounded_run.cpp new file mode 100644 index 000000000..307149de6 --- /dev/null +++ b/test/unit/bounded_run.cpp @@ -0,0 +1,249 @@ +// +// 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 +// + +// Bounded run variants (run_for / run_one_for / poll) on the reactor +// schedulers. Durations here bound how long a call may block; nothing +// asserts elapsed time, only that parked work stays parked and armed +// timers still fire within the bound. + +#include + +#if BOOST_COROSIO_HAS_EPOLL || BOOST_COROSIO_HAS_KQUEUE || \ + BOOST_COROSIO_HAS_SELECT + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "context.hpp" +#include "test_suite.hpp" + +namespace boost::corosio { + +template +struct bounded_run_test +{ + void testRunForLeavesParkedReadParked() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + char buf[8]; + std::error_code rec; + bool resumed = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + resumed = true; + }; + capy::run_async(ex)(reader()); + + std::ignore = ioc.run_for(std::chrono::milliseconds(50)); + BOOST_TEST(!resumed); + + s1.cancel(); + ioc.restart(); + ioc.run(); + BOOST_TEST(resumed); + BOOST_TEST(rec == capy::cond::canceled); + } + + void testRunOneForLeavesParkedReadParked() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + char buf[8]; + bool resumed = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + std::ignore = ec; + resumed = true; + }; + capy::run_async(ex)(reader()); + + // First slice starts the reader coroutine and parks it; the + // second finds only the parked read and waits out the bound. + std::ignore = ioc.run_one_for(std::chrono::milliseconds(20)); + std::ignore = ioc.run_one_for(std::chrono::milliseconds(20)); + BOOST_TEST(!resumed); + + s1.cancel(); + ioc.restart(); + ioc.run(); + BOOST_TEST(resumed); + } + + void testRunForFiresArmedDelay() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + + bool fired = false; + auto task = [&]() -> capy::task<> { + std::ignore = + co_await corosio::delay(std::chrono::milliseconds(10)); + fired = true; + }; + capy::run_async(ex)(task()); + + // The bound exceeds the delay, so the scheduler's timed wait + // must be capped by the timer and the delay must complete. + std::ignore = ioc.run_for(std::chrono::seconds(20)); + BOOST_TEST(fired); + } + + void testPollLeavesParkedReadParked() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + char buf[8]; + bool resumed = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + std::ignore = ec; + resumed = true; + }; + capy::run_async(ex)(reader()); + + std::ignore = ioc.poll(); + BOOST_TEST(!resumed); + ioc.restart(); + std::ignore = ioc.poll_one(); + BOOST_TEST(!resumed); + + // Queued work alongside the parked read makes the reactor pass + // run with a zero timeout instead of blocking. + bool posted = false; + auto nop = [&]() -> capy::task<> { + posted = true; + co_return; + }; + capy::run_async(ex)(nop()); + ioc.restart(); + std::ignore = ioc.poll(); + BOOST_TEST(posted); + BOOST_TEST(!resumed); + + // With nothing queued the reactor pass itself runs with a + // zero timeout. + ioc.restart(); + std::ignore = ioc.poll(); + BOOST_TEST(!resumed); + + s1.cancel(); + ioc.restart(); + ioc.run(); + BOOST_TEST(resumed); + } + + void testTwoThreadBoundedFollower() + { + // Two threads inside the scheduler: one holds reactor + // leadership on a parked read, the other's bounded slices take + // the follower timed wait. Bounds cap blocking; nothing + // asserts elapsed time. + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + char buf[8]; + bool resumed = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + std::ignore = ec; + resumed = true; + }; + capy::run_async(ex)(reader()); + + std::thread follower([&] { + for (int i = 0; i < 5 && !resumed; ++i) + { + std::ignore = ioc.run_one_for(std::chrono::milliseconds(20)); + std::ignore = ioc.poll(); + } + }); + for (int i = 0; i < 5 && !resumed; ++i) + std::ignore = ioc.run_one_for(std::chrono::milliseconds(20)); + follower.join(); + BOOST_TEST(!resumed); + + s1.cancel(); + ioc.restart(); + ioc.run(); + BOOST_TEST(resumed); + } + + + void testOversizeBudgetThrows() + { + io_context_options opts; + opts.inline_budget_max = + (std::numeric_limits::max)(); + BOOST_TEST_THROWS( + ([&] { io_context tmp(Backend, opts); }()), std::out_of_range); + } + + // run_one() on a context with no outstanding work returns 0 at once, + // taking the idle early-out before the event loop. + void testRunOneOnIdleReturnsZero() + { + io_context ioc(Backend); + BOOST_TEST(ioc.run_one() == 0); + } + + void run() + { + testRunForLeavesParkedReadParked(); + testRunOneForLeavesParkedReadParked(); + testRunForFiresArmedDelay(); + testPollLeavesParkedReadParked(); + testTwoThreadBoundedFollower(); + testOversizeBudgetThrows(); + testRunOneOnIdleReturnsZero(); + } +}; + +COROSIO_REACTOR_BACKEND_TESTS(bounded_run_test, "boost.corosio.bounded_run") + +} // namespace boost::corosio + +#endif From b8b85fb5a14e50ba00ce85d00b72d646a6e23873 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 19:37:46 +0200 Subject: [PATCH 11/29] test(io_uring): drain in-flight and reaped ops at context teardown --- test/unit/teardown_inflight.cpp | 525 ++++++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 test/unit/teardown_inflight.cpp diff --git a/test/unit/teardown_inflight.cpp b/test/unit/teardown_inflight.cpp new file mode 100644 index 000000000..0bb676902 --- /dev/null +++ b/test/unit/teardown_inflight.cpp @@ -0,0 +1,525 @@ +// +// 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 +// + +// Destroying the io_context while io_uring operations are still in +// the ring must drain them without resuming or touching the awaiting +// coroutines. Companion tests drive the multishot acceptor's wait and +// close paths that only exist on the io_uring backend. + +#include + +#if BOOST_COROSIO_HAS_IO_URING + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "context.hpp" +#include "test_suite.hpp" + +namespace boost::corosio { + +namespace { + +[[maybe_unused]] void +fill_fd(int fd) +{ + char junk[4096] = {}; + ssize_t n; + do + { + n = ::send(fd, junk, sizeof(junk), MSG_DONTWAIT | MSG_NOSIGNAL); + } while (n > 0); +} + +[[maybe_unused]] void +fill_pipe(int fd) +{ + char junk[4096] = {}; + ssize_t n; + do + { + n = ::write(fd, junk, sizeof(junk)); + } while (n > 0); +} + +struct temp_file +{ + std::filesystem::path path; + + explicit temp_file(std::string_view contents) + { + static int counter = 0; + path = std::filesystem::temp_directory_path() / + ("corosio_teardown_" + std::to_string(::getpid()) + "_" + + std::to_string(counter++)); + std::ofstream(path) << contents; + } + + ~temp_file() + { + std::error_code ec; + std::filesystem::remove(path, ec); + } + + temp_file(temp_file const&) = delete; + temp_file& operator=(temp_file const&) = delete; +}; + +} // namespace + +struct io_uring_teardown_test +{ + void testAcceptorWaitAfterClose() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + acc.close(); + + std::error_code wec; + bool done = false; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await acc.wait(wait_type::read); + wec = ec; + done = true; + }; + capy::run_async(ex)(waiter()); + ioc.run(); + + BOOST_TEST(done); + BOOST_TEST(!!wec); + } + + void testAcceptorWaitStopRequested() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + + std::stop_source ss; + std::error_code wec; + bool done = false; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await acc.wait(wait_type::read); + wec = ec; + done = true; + }; + auto stopper = [&]() -> capy::task<> { + ss.request_stop(); + co_return; + }; + capy::run_async(ex, ss.get_token())(waiter()); + capy::run_async(ex)(stopper()); + ioc.run(); + + BOOST_TEST(done); + BOOST_TEST(wec == capy::cond::canceled); + } + + void testAcceptorCloseWithParkedWait() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + + std::error_code wec, aec; + tcp_socket peer(ioc); + int done = 0; + auto accepter = [&]() -> capy::task<> { + auto [ec] = co_await acc.accept(peer); + aec = ec; + ++done; + }; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await acc.wait(wait_type::read); + wec = ec; + ++done; + }; + auto closer = [&]() -> capy::task<> { + acc.close(); + co_return; + }; + capy::run_async(ex)(accepter()); + capy::run_async(ex)(waiter()); + capy::run_async(ex)(closer()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(!!aec); + BOOST_TEST(!!wec); + } + +#if !COROSIO_TEST_HAS_ASAN + // These abandon parked coroutine frames by design; see context.hpp. + + void testDestroyWithPendingSocketWrite() + { + bool resumed = false; + { + io_context ioc(io_uring); + // The pair is made outside the coroutine (it drains the + // context internally); the socket moves into the frame so + // its fd stays open when the frame is abandoned. + auto [s1, s2] = + test::make_socket_pair(ioc); + fill_fd(static_cast(s1.native_handle())); + auto keeper = [](tcp_socket s, bool& flag) -> capy::task<> { + char big[65536] = {}; + std::ignore = co_await s.write_some( + capy::const_buffer(big, sizeof(big))); + flag = true; + }(std::move(s1), resumed); + capy::run_async(ioc.get_executor())(std::move(keeper)); + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } + + void testDestroyWithPendingDatagramSend() + { + bool resumed = false; + { + io_context ioc(io_uring); + local_datagram_socket d1(ioc), d2(ioc); + if (auto ec = connect_pair(d1, d2)) + throw std::system_error(ec, "connect_pair"); + fill_fd(static_cast(d1.native_handle())); + auto keeper = [](local_datagram_socket d, bool& flag) + -> capy::task<> { + std::ignore = co_await d.send(capy::const_buffer("x", 1)); + flag = true; + }(std::move(d1), resumed); + capy::run_async(ioc.get_executor())(std::move(keeper)); + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } + + void testDestroyWithPendingFileOps() + { + // The counterpart end of each pipe stays raw and open past the + // context so the drained ops never hit a broken pipe. + int rp[2], wp[2]; + BOOST_TEST(::pipe2(rp, O_NONBLOCK) == 0); + BOOST_TEST(::pipe2(wp, O_NONBLOCK) == 0); + fill_pipe(wp[1]); + + bool read_resumed = false, write_resumed = false; + { + io_context ioc(io_uring); + auto reader = [&]() -> capy::task<> { + stream_file f(ioc); + std::ignore = f.assign(static_cast(rp[0])); + char buf[16]; + std::ignore = co_await f.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + read_resumed = true; + }; + auto writer = [&]() -> capy::task<> { + stream_file f(ioc); + std::ignore = f.assign(static_cast(wp[1])); + char big[4096] = {}; + std::ignore = co_await f.write_some( + capy::const_buffer(big, sizeof(big))); + write_resumed = true; + }; + capy::run_async(ioc.get_executor())(reader()); + capy::run_async(ioc.get_executor())(writer()); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + } + ::close(rp[1]); + ::close(wp[0]); + BOOST_TEST(!read_resumed); + BOOST_TEST(!write_resumed); + } + + void testDestroyWithSubmittedRandomAccessOps() + { + // The ops complete in the ring almost immediately, but nothing + // processes the completions before teardown, so the drain path + // must reclaim them. + temp_file tmp("hello world"); + auto const path = tmp.path; + bool read_resumed = false, write_resumed = false; + { + io_context ioc(io_uring); + auto reader = [&]() -> capy::task<> { + random_access_file f(ioc); + std::ignore = f.open(path, file_base::read_write); + char buf[8]; + std::ignore = co_await f.read_some_at( + 0, capy::mutable_buffer(buf, sizeof(buf))); + read_resumed = true; + }; + auto writer = [&]() -> capy::task<> { + random_access_file f(ioc); + std::ignore = f.open(path, file_base::read_write); + std::ignore = + co_await f.write_some_at(0, capy::const_buffer("x", 1)); + write_resumed = true; + }; + capy::run_async(ioc.get_executor())(reader()); + capy::run_async(ioc.get_executor())(writer()); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + } + BOOST_TEST(!read_resumed); + BOOST_TEST(!write_resumed); + } + + // Two completable ops of one kind: three run_one() calls start + // both and dispatch one completion, leaving the second reaped but + // undispatched when the context dies, so the drain must run its + // handler ownerless. + + void testDestroyWithQueuedSocketWrites() + { + int resumed = 0; + { + io_context ioc(io_uring); + auto [s1, s2] = + test::make_socket_pair(ioc); + auto [s3, s4] = + test::make_socket_pair(ioc); + fill_fd(static_cast(s1.native_handle())); + fill_fd(static_cast(s3.native_handle())); + char big[65536] = {}; + auto writer = [](tcp_socket& s, capy::const_buffer b, + int& count) -> capy::task<> { + std::ignore = co_await s.write_some(b); + ++count; + }; + capy::run_async(ioc.get_executor())( + writer(s1, capy::const_buffer(big, sizeof(big)), resumed)); + capy::run_async(ioc.get_executor())( + writer(s3, capy::const_buffer(big, sizeof(big)), resumed)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + // Raw-drain both peers so the parked writes complete in + // the ring; one more slice reaps and dispatches one. + char sink[65536]; + for (auto* peer : {&s2, &s4}) + { + while (::recv(static_cast(peer->native_handle()), sink, + sizeof(sink), MSG_DONTWAIT) > 0) + { + } + } + std::ignore = ioc.run_one(); + } + BOOST_TEST_LT(resumed, 2); + } + + void testDestroyWithQueuedDatagramSends() + { + int resumed = 0; + { + io_context ioc(io_uring); + local_datagram_socket d1(ioc), d2(ioc), d3(ioc), d4(ioc); + if (auto ec = connect_pair(d1, d2)) + throw std::system_error(ec, "connect_pair"); + if (auto ec = connect_pair(d3, d4)) + throw std::system_error(ec, "connect_pair"); + fill_fd(static_cast(d1.native_handle())); + fill_fd(static_cast(d3.native_handle())); + auto sender = + [](local_datagram_socket& d, int& count) -> capy::task<> { + std::ignore = co_await d.send(capy::const_buffer("x", 1)); + ++count; + }; + capy::run_async(ioc.get_executor())(sender(d1, resumed)); + capy::run_async(ioc.get_executor())(sender(d3, resumed)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + char sink[4096]; + for (auto* peer : {&d2, &d4}) + { + while (::recv(static_cast(peer->native_handle()), sink, + sizeof(sink), MSG_DONTWAIT) > 0) + { + } + } + std::ignore = ioc.run_one(); + } + BOOST_TEST_LT(resumed, 2); + } + + void testDestroyWithQueuedFileOps() + { + int rp[2], wp[2]; + BOOST_TEST(::pipe2(rp, O_NONBLOCK) == 0); + BOOST_TEST(::pipe2(wp, O_NONBLOCK) == 0); + std::ignore = ::write(rp[1], "seed-data-16byte", 16); + + int resumed = 0; + { + io_context ioc(io_uring); + auto reader = [](io_context& ctx, int fd, int& count) + -> capy::task<> { + stream_file f(ctx); + std::ignore = f.assign(static_cast(fd)); + char buf[4]; + std::ignore = co_await f.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + ++count; + std::ignore = co_await f.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + ++count; + }; + auto writer = [](io_context& ctx, int fd, int& count) + -> capy::task<> { + stream_file f(ctx); + std::ignore = f.assign(static_cast(fd)); + std::ignore = + co_await f.write_some(capy::const_buffer("a", 1)); + ++count; + std::ignore = + co_await f.write_some(capy::const_buffer("b", 1)); + ++count; + }; + capy::run_async(ioc.get_executor())(reader(ioc, rp[0], resumed)); + capy::run_async(ioc.get_executor())(writer(ioc, wp[1], resumed)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + } + ::close(rp[1]); + ::close(wp[0]); + BOOST_TEST_LT(resumed, 4); + } + + void testDestroyWithQueuedRandomAccessOps() + { + temp_file tmp("hello world"); + auto const path = tmp.path; + int resumed = 0; + { + io_context ioc(io_uring); + auto reader = [](io_context& ctx, std::filesystem::path p, + int& count) -> capy::task<> { + random_access_file f(ctx); + std::ignore = f.open(p, file_base::read_write); + char buf[4]; + std::ignore = co_await f.read_some_at( + 0, capy::mutable_buffer(buf, sizeof(buf))); + ++count; + std::ignore = co_await f.read_some_at( + 1, capy::mutable_buffer(buf, sizeof(buf))); + ++count; + }; + auto writer = [](io_context& ctx, std::filesystem::path p, + int& count) -> capy::task<> { + random_access_file f(ctx); + std::ignore = f.open(p, file_base::read_write); + std::ignore = + co_await f.write_some_at(0, capy::const_buffer("x", 1)); + ++count; + std::ignore = + co_await f.write_some_at(1, capy::const_buffer("y", 1)); + ++count; + }; + capy::run_async(ioc.get_executor())(reader(ioc, path, resumed)); + capy::run_async(ioc.get_executor())(writer(ioc, path, resumed)); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + } + BOOST_TEST_LT(resumed, 4); + } + + + void testDestroyWithParkedLocalAccept() + { + bool resumed = false; + { + io_context ioc(io_uring); + auto keeper = [&]() -> capy::task<> { + test::temp_socket_dir tmp; + local_stream_acceptor acc(ioc); + std::ignore = acc.open(); + std::ignore = acc.bind(local_endpoint(tmp.path())); + std::ignore = acc.listen(); + local_stream_socket peer(ioc); + std::ignore = co_await acc.accept(peer); + resumed = true; + }; + capy::run_async(ioc.get_executor())(keeper()); + std::ignore = ioc.run_one(); + } + BOOST_TEST(!resumed); + } +#endif // !COROSIO_TEST_HAS_ASAN + + void run() + { + testAcceptorWaitAfterClose(); + testAcceptorWaitStopRequested(); + testAcceptorCloseWithParkedWait(); +#if !COROSIO_TEST_HAS_ASAN + testDestroyWithPendingSocketWrite(); + testDestroyWithPendingDatagramSend(); + testDestroyWithPendingFileOps(); + testDestroyWithSubmittedRandomAccessOps(); + testDestroyWithQueuedSocketWrites(); + testDestroyWithQueuedDatagramSends(); + testDestroyWithQueuedFileOps(); + testDestroyWithQueuedRandomAccessOps(); + testDestroyWithParkedLocalAccept(); +#endif + } +}; + +TEST_SUITE(io_uring_teardown_test, "boost.corosio.io_uring_teardown"); + +} // namespace boost::corosio + +#endif // BOOST_COROSIO_HAS_IO_URING From 4eb7d75c79d82fc286c64164a81de2f8f40f3107 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 20:06:56 +0200 Subject: [PATCH 12/29] fix(io_uring): absorb SIGPIPE when teardown flushes a broken pipe write Service shutdown closes impls one at a time, so a queued pipe write can execute against a reader the same shutdown already closed. The kernel raises thread-directed SIGPIPE at the next kernel entry, which kills any process that has not ignored the signal, even though the CQE already reports EPIPE. Block and consume the signal around the close and ring-teardown paths that can run such a write. --- .../detail/io_uring/io_uring_file_ops.hpp | 6 +- .../io_uring/io_uring_random_access_file.hpp | 4 ++ .../detail/io_uring/io_uring_scheduler.hpp | 61 +++++++++++++++++++ .../detail/io_uring/io_uring_stream_file.hpp | 4 ++ test/unit/teardown_inflight.cpp | 40 ++++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_file_ops.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_file_ops.hpp index 99a26e339..75e9fb407 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_file_ops.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_file_ops.hpp @@ -180,8 +180,10 @@ struct uring_random_access_read_op : uring_file_read_op_base Stream files pass `offset == -1` (kernel f_pos); random-access files pass an explicit caller-supplied offset. Unlike socket - writes, no `MSG_NOSIGNAL` is needed — files don't generate - SIGPIPE on closed peers. + writes there is no `MSG_NOSIGNAL` equivalent: a write to a pipe + or FIFO whose reader has closed raises SIGPIPE when the kernel + executes it, so the teardown paths that flush queued writes hold + the signal blocked (see `scoped_sigpipe_block`). */ /// Shared state and submission logic for file write ops. Concrete /// subclasses pick a `do_handler` matching their storage model. diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_random_access_file.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_random_access_file.hpp index a41c4551e..134a4560d 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_random_access_file.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_random_access_file.hpp @@ -207,6 +207,10 @@ class BOOST_COROSIO_DECL io_uring_random_access_file final { if (fd_ >= 0) { + // The kernel may run a queued pipe write as task work at + // either kernel entry below; with the reader already gone + // that raises SIGPIPE. + scoped_sigpipe_block no_sigpipe; sched_->cancel_and_flush(fd_); ::close(fd_); fd_ = -1; 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 2f5b471fb..5ff53ca30 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 @@ -46,11 +46,63 @@ #include #include +#include +#include #include +#include #include namespace boost::corosio::detail { +/** Block SIGPIPE on the calling thread for the current scope. + + A queued pipe write can execute inline while this thread is in + the kernel submitting SQEs; if the pipe's reader is already gone + the kernel raises a thread-directed SIGPIPE, which kills any + process that has not ignored the signal. The write's CQE still + reports `EPIPE`, so the signal carries no information the + completion path does not already deliver. The destructor consumes + any SIGPIPE raised while blocked and restores the caller's mask. +*/ +class scoped_sigpipe_block +{ + sigset_t old_{}; + bool restore_ = false; + +public: + /// Consume any SIGPIPE raised in scope and restore the mask. + ~scoped_sigpipe_block() + { + if (!restore_) + return; + if (!sigismember(&old_, SIGPIPE)) + { + // Only consume what this scope could have generated; a + // caller who blocked SIGPIPE keeps their pending state. + sigset_t set; + sigemptyset(&set); + sigaddset(&set, SIGPIPE); + timespec zero{}; + while (::sigtimedwait(&set, nullptr, &zero) == SIGPIPE) + { + } + } + ::pthread_sigmask(SIG_SETMASK, &old_, nullptr); + } + + /// Construct and block SIGPIPE for the calling thread. + scoped_sigpipe_block() noexcept + { + sigset_t set; + sigemptyset(&set); + sigaddset(&set, SIGPIPE); + restore_ = ::pthread_sigmask(SIG_BLOCK, &set, &old_) == 0; + } + + scoped_sigpipe_block(scoped_sigpipe_block const&) = delete; + scoped_sigpipe_block& operator=(scoped_sigpipe_block const&) = delete; +}; + // Forward-declared so the out-of-line inline definitions below the class // can reference the frame stack without a circular dependency. struct io_uring_scheduler_frame; @@ -556,6 +608,9 @@ io_uring_scheduler::~io_uring_scheduler() { if (ring_inited_) { + // Ring teardown can still run a doomed pipe write as task + // work; absorb the SIGPIPE it would raise. + scoped_sigpipe_block no_sigpipe; if (wakeup_eventfd_ >= 0) ::close(wakeup_eventfd_); ::io_uring_queue_exit(&ring_); @@ -1493,6 +1548,12 @@ io_uring_op::on_cancel() noexcept inline void io_uring_scheduler::cancel_and_flush(int fd) noexcept { + // The flush can execute a queued write on `fd` inline; when the + // fd is a pipe whose reader has already closed — service + // shutdown closes impls one at a time, so teardown itself + // creates that state — the kernel raises SIGPIPE. + scoped_sigpipe_block no_sigpipe; + lazy_init_ring(); interrupt_reactor(); lock_type lock(ring_mutex_); diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_stream_file.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_stream_file.hpp index b61e1ac35..6431aa23c 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_stream_file.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_stream_file.hpp @@ -229,6 +229,10 @@ class BOOST_COROSIO_DECL io_uring_stream_file final { if (fd_ >= 0) { + // The kernel may run a queued pipe write as task work at + // either kernel entry below; with the reader already gone + // that raises SIGPIPE. + scoped_sigpipe_block no_sigpipe; sched_->cancel_and_flush(fd_); ::close(fd_); fd_ = -1; diff --git a/test/unit/teardown_inflight.cpp b/test/unit/teardown_inflight.cpp index 0bb676902..70adc1f6f 100644 --- a/test/unit/teardown_inflight.cpp +++ b/test/unit/teardown_inflight.cpp @@ -321,6 +321,45 @@ struct io_uring_teardown_test // undispatched when the context dies, so the drain must run its // handler ownerless. + void testDestroyWithBrokenPipeWriteSurvives() + { + // Both ends of one pipe are wrapped, with a write parked on + // the full pipe. Service shutdown closes the read end first, + // so the flushed write executes against a broken pipe; the + // library must absorb the SIGPIPE instead of dying. + int p[2]; + BOOST_TEST(::pipe2(p, O_NONBLOCK) == 0); + fill_pipe(p[1]); + + bool read_resumed = false, write_resumed = false; + { + io_context ioc(io_uring); + auto reader = [&]() -> capy::task<> { + stream_file f(ioc); + std::ignore = f.assign(static_cast(p[0])); + char buf[16]; + std::ignore = co_await f.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + read_resumed = true; + }; + auto writer = [&]() -> capy::task<> { + stream_file f(ioc); + std::ignore = f.assign(static_cast(p[1])); + char big[4096] = {}; + std::ignore = co_await f.write_some( + capy::const_buffer(big, sizeof(big))); + write_resumed = true; + }; + capy::run_async(ioc.get_executor())(reader()); + capy::run_async(ioc.get_executor())(writer()); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + } + BOOST_TEST(!read_resumed); + BOOST_TEST(!write_resumed); + } + + void testDestroyWithQueuedSocketWrites() { int resumed = 0; @@ -509,6 +548,7 @@ struct io_uring_teardown_test testDestroyWithPendingDatagramSend(); testDestroyWithPendingFileOps(); testDestroyWithSubmittedRandomAccessOps(); + testDestroyWithBrokenPipeWriteSurvives(); testDestroyWithQueuedSocketWrites(); testDestroyWithQueuedDatagramSends(); testDestroyWithQueuedFileOps(); From f5d1facf133b07b7ea7a5936893236d0958caba7 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 4 Sep 2026 22:55:02 +0200 Subject: [PATCH 13/29] refactor(reactor): drop unreachable descriptor, datagram, and queue paths Coverage analysis surfaced reactor paths no execution can reach: the per-descriptor cancel-pending flags nothing ever sets, the datagram connect EINPROGRESS parking a datagram connect never enters, and the private-queue drains -- along with the do_one check that guarded them -- that work_cleanup and task_cleanup make dead by splicing the private queue to the global queue after every handler and every reactor pass. The cleanup guards' own null-context branches are dead too: do_one is their only source of a context and always passes a live frame. Remove them rather than carry dead branches. --- .../detail/reactor/reactor_basic_socket.hpp | 25 +---- .../reactor/reactor_datagram_socket.hpp | 77 ++++----------- .../reactor/reactor_descriptor_state.hpp | 18 ---- .../detail/reactor/reactor_scheduler.hpp | 99 ++++--------------- .../detail/reactor/reactor_stream_socket.hpp | 32 +----- test/unit/reactor_paths.cpp | 2 +- 6 files changed, 41 insertions(+), 212 deletions(-) diff --git a/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp b/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp index 388dbd83d..7acc671f1 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_basic_socket.hpp @@ -117,16 +117,14 @@ class reactor_basic_socket /** Register an op with the reactor. - Handles cached edge events and deferred cancellation. - Called on the EAGAIN/EINPROGRESS path when speculative - I/O failed. + Handles cached edge events. Called on the EAGAIN/EINPROGRESS + path when speculative I/O failed. */ template void register_op( Op& op, reactor_op_base*& desc_slot, bool& ready_flag, - bool& cancel_flag, bool is_write_direction = false) noexcept; /** Cancel a single pending operation. @@ -135,7 +133,6 @@ class reactor_basic_socket the mutex and posts it to the scheduler as cancelled. Derived must implement: op_to_desc_slot(Op&) -> reactor_op_base** - op_to_cancel_flag(Op&) -> bool* */ template void cancel_single_op(Op& op) noexcept; @@ -175,7 +172,6 @@ reactor_basic_socket::register_ Op& op, reactor_op_base*& desc_slot, bool& ready_flag, - bool& cancel_flag, bool is_write_direction) noexcept { svc_.work_started(); @@ -191,11 +187,6 @@ reactor_basic_socket::register_ op.errn = 0; } - if (cancel_flag) - { - cancel_flag = false; - op.cancelled.store(true, std::memory_order_relaxed); - } if (io_done || op.cancelled.load(std::memory_order_acquire)) { @@ -332,12 +323,6 @@ reactor_basic_socket:: }); desc_state_.read_ready = false; desc_state_.write_ready = false; - desc_state_.read_cancel_pending = false; - desc_state_.write_cancel_pending = false; - desc_state_.connect_cancel_pending = false; - desc_state_.wait_read_cancel_pending = false; - desc_state_.wait_write_cancel_pending = false; - desc_state_.wait_error_cancel_pending = false; if (desc_state_.is_enqueued_.load(std::memory_order_acquire)) desc_state_.impl_ref_ = self; @@ -398,12 +383,6 @@ reactor_basic_socket:: }); desc_state_.read_ready = false; desc_state_.write_ready = false; - desc_state_.read_cancel_pending = false; - desc_state_.write_cancel_pending = false; - desc_state_.connect_cancel_pending = false; - desc_state_.wait_read_cancel_pending = false; - desc_state_.wait_write_cancel_pending = false; - desc_state_.wait_error_cancel_pending = false; if (desc_state_.is_enqueued_.load(std::memory_order_acquire)) desc_state_.impl_ref_ = self; diff --git a/include/boost/corosio/native/detail/reactor/reactor_datagram_socket.hpp b/include/boost/corosio/native/detail/reactor/reactor_datagram_socket.hpp index cac7fe000..6a29f18e6 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_datagram_socket.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_datagram_socket.hpp @@ -248,7 +248,8 @@ class reactor_datagram_socket Tries connect() speculatively. On synchronous completion, returns via inline budget or posts through queue. - On EINPROGRESS, registers with the reactor. + The result is always synchronous: a datagram connect only + records the peer address in the kernel. */ std::coroutine_handle<> do_connect( std::coroutine_handle<>, @@ -372,27 +373,6 @@ class reactor_datagram_socket return nullptr; } - template - bool* op_to_cancel_flag(Op& op) noexcept - { - if (&op == static_cast(&conn_)) - return &this->desc_state_.connect_cancel_pending; - if (&op == static_cast(&rd_)) - return &this->desc_state_.read_cancel_pending; - if (&op == static_cast(&wr_)) - return &this->desc_state_.write_cancel_pending; - if (&op == static_cast(&recv_rd_)) - return &this->desc_state_.read_cancel_pending; - if (&op == static_cast(&send_wr_)) - return &this->desc_state_.write_cancel_pending; - if (&op == static_cast(&wait_rd_)) - return &this->desc_state_.wait_read_cancel_pending; - if (&op == static_cast(&wait_wr_)) - return &this->desc_state_.wait_write_cancel_pending; - if (&op == static_cast(&wait_er_)) - return &this->desc_state_.wait_error_cancel_pending; - return nullptr; - } template void for_each_op(Fn fn) noexcept @@ -528,8 +508,7 @@ reactor_datagram_socket< op.impl_ptr = this->shared_from_this(); this->register_op( - op, this->desc_state_.write_op, this->desc_state_.write_ready, - this->desc_state_.write_cancel_pending, true); + op, this->desc_state_.write_op, this->desc_state_.write_ready, true); return std::noop_coroutine(); } @@ -653,8 +632,7 @@ reactor_datagram_socket< op.impl_ptr = this->shared_from_this(); this->register_op( - op, this->desc_state_.read_op, this->desc_state_.read_ready, - this->desc_state_.read_cancel_pending); + op, this->desc_state_.read_op, this->desc_state_.read_ready); return std::noop_coroutine(); } @@ -711,29 +689,16 @@ reactor_datagram_socket< remote_endpoint_ = ep; } - if (result == 0 || errno != EINPROGRESS) + // A datagram connect cannot block — it only records the peer + // address — so the result is handled synchronously; the deferred + // arm exists for a spent inline budget, not for a parked op. + int err = (result < 0) ? errno : 0; + if (this->svc_.scheduler().try_consume_inline_budget()) { - int err = (result < 0) ? errno : 0; - if (this->svc_.scheduler().try_consume_inline_budget()) - { - *ec = err ? make_err(err) : std::error_code{}; - op.cont.h = h; - return dispatch_coro(ex, op.cont); - } - op.reset(); - op.h = h; - op.ex = ex; - op.ec_out = ec; - op.fd = this->fd_; - op.target_endpoint = ep; - op.start(token, static_cast(this)); - op.impl_ptr = this->shared_from_this(); - op.complete(err, 0); - this->svc_.post(&op); - return std::noop_coroutine(); + *ec = err ? make_err(err) : std::error_code{}; + op.cont.h = h; + return dispatch_coro(ex, op.cont); } - - // EINPROGRESS — register with reactor op.reset(); op.h = h; op.ex = ex; @@ -742,10 +707,8 @@ reactor_datagram_socket< op.target_endpoint = ep; op.start(token, static_cast(this)); op.impl_ptr = this->shared_from_this(); - - this->register_op( - op, this->desc_state_.connect_op, this->desc_state_.write_ready, - this->desc_state_.connect_cancel_pending); + op.complete(err, 0); + this->svc_.post(&op); return std::noop_coroutine(); } @@ -850,8 +813,7 @@ reactor_datagram_socket< op.impl_ptr = this->shared_from_this(); this->register_op( - op, this->desc_state_.write_op, this->desc_state_.write_ready, - this->desc_state_.write_cancel_pending, true); + op, this->desc_state_.write_op, this->desc_state_.write_ready, true); return std::noop_coroutine(); } @@ -963,8 +925,7 @@ reactor_datagram_socket< op.impl_ptr = this->shared_from_this(); this->register_op( - op, this->desc_state_.read_op, this->desc_state_.read_ready, - this->desc_state_.read_cancel_pending); + op, this->desc_state_.read_op, this->desc_state_.read_ready); return std::noop_coroutine(); } @@ -1004,28 +965,24 @@ reactor_datagram_socket< { WaitOp* op_ptr; reactor_op_base** desc_slot_ptr; - bool* cancel_flag_ptr; std::uint32_t event; if (w == wait_type::read) { op_ptr = &wait_rd_; desc_slot_ptr = &this->desc_state_.wait_read_op; - cancel_flag_ptr = &this->desc_state_.wait_read_cancel_pending; event = reactor_event_read; } else if (w == wait_type::write) { op_ptr = &wait_wr_; desc_slot_ptr = &this->desc_state_.wait_write_op; - cancel_flag_ptr = &this->desc_state_.wait_write_cancel_pending; event = reactor_event_write; } else // wait_type::error { op_ptr = &wait_er_; desc_slot_ptr = &this->desc_state_.wait_error_op; - cancel_flag_ptr = &this->desc_state_.wait_error_cancel_pending; event = reactor_event_error; } @@ -1073,7 +1030,7 @@ reactor_datagram_socket< // otherwise leave the wait parked on a ready socket. bool force_probe = true; this->register_op( - op, *desc_slot_ptr, force_probe, *cancel_flag_ptr, + op, *desc_slot_ptr, force_probe, event == reactor_event_write); return std::noop_coroutine(); } 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 cf0a0c41f..4dda0eb76 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_descriptor_state.hpp @@ -72,24 +72,6 @@ struct reactor_descriptor_state : scheduler_op /// True if a write edge event arrived before an op was registered. bool write_ready = false; - /// Deferred read cancellation (IOCP-style cancel semantics). - bool read_cancel_pending = false; - - /// Deferred write cancellation (IOCP-style cancel semantics). - bool write_cancel_pending = false; - - /// Deferred connect cancellation (IOCP-style cancel semantics). - bool connect_cancel_pending = false; - - /// Deferred wait-read cancellation (IOCP-style cancel semantics). - bool wait_read_cancel_pending = false; - - /// Deferred wait-write cancellation (IOCP-style cancel semantics). - bool wait_write_cancel_pending = false; - - /// Deferred wait-error cancellation (IOCP-style cancel semantics). - bool wait_error_cancel_pending = false; - /// Event mask set during registration (no mutex needed). std::uint32_t registered_events = 0; diff --git a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp index aa0d74833..2035cdecf 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp @@ -86,37 +86,6 @@ reactor_find_context(reactor_scheduler const* self) noexcept return nullptr; } -/// Flush private work count to global counter. -inline void -reactor_flush_private_work( - reactor_scheduler_context* ctx, - std::atomic& outstanding_work) noexcept -{ - if (ctx && ctx->private_outstanding_work > 0) - { - outstanding_work.fetch_add( - ctx->private_outstanding_work, std::memory_order_relaxed); - ctx->private_outstanding_work = 0; - } -} - -/** Drain private queue to global queue, flushing work count first. - - @return True if any ops were drained. -*/ -inline bool -reactor_drain_private_queue( - reactor_scheduler_context* ctx, - std::atomic& outstanding_work, - ready_queue& completed_ops) noexcept -{ - if (!ctx || ctx->private_queue.empty()) - return false; - - reactor_flush_private_work(ctx, outstanding_work); - completed_ops.splice(ctx->private_queue); - return true; -} /** Non-template base for reactor-backed scheduler implementations. @@ -212,15 +181,6 @@ class reactor_scheduler */ void compensating_work_started() const noexcept; - /** Drain work from thread context's private queue to global queue. - - Flushes private work count to the global counter, then - transfers the queue under mutex protection. - - @param queue The private queue to drain. - @param count Private work count to flush before draining. - */ - void drain_thread_queue(ready_queue& queue, std::int64_t count) const; /** Post completed operations for deferred invocation. @@ -387,12 +347,14 @@ struct reactor_thread_context_guard reactor_context_stack.set(&frame_); } - /// Destroy the guard, draining private work and popping the frame. + /** Destroy the guard, popping the frame. + + The private queue is empty here by invariant: work_cleanup and + task_cleanup splice it to the global queue after every handler + and every reactor pass. + */ ~reactor_thread_context_guard() noexcept { - if (!frame_.private_queue.empty()) - frame_.key->drain_thread_queue( - frame_.private_queue, frame_.private_outstanding_work); reactor_context_stack.set(frame_.next); } }; @@ -705,18 +667,6 @@ reactor_scheduler::compensating_work_started() const noexcept ++ctx->private_outstanding_work; } -inline void -reactor_scheduler::drain_thread_queue( - ready_queue& queue, std::int64_t count) const -{ - if (count > 0) - outstanding_work_.fetch_add(count, std::memory_order_relaxed); - - lock_type lock(mutex_); - completed_ops_.splice(queue); - if (count > 0) - maybe_unlock_and_signal_one(lock); -} inline void reactor_scheduler::post_deferred_completions(ready_queue& ops) const @@ -847,33 +797,23 @@ reactor_scheduler::wake_one_thread_and_unlock( inline reactor_scheduler::work_cleanup::~work_cleanup() { - if (ctx) - { - std::int64_t produced = ctx->private_outstanding_work; - if (produced > 1) - sched->outstanding_work_.fetch_add( - produced - 1, std::memory_order_relaxed); - else if (produced < 1) - sched->work_finished(); - ctx->private_outstanding_work = 0; + std::int64_t produced = ctx->private_outstanding_work; + if (produced > 1) + sched->outstanding_work_.fetch_add( + produced - 1, std::memory_order_relaxed); + else if (produced < 1) + sched->work_finished(); + ctx->private_outstanding_work = 0; - if (!ctx->private_queue.empty()) - { - lock->lock(); - sched->completed_ops_.splice(ctx->private_queue); - } - } - else + if (!ctx->private_queue.empty()) { - sched->work_finished(); + lock->lock(); + sched->completed_ops_.splice(ctx->private_queue); } } inline reactor_scheduler::task_cleanup::~task_cleanup() { - if (!ctx) - return; - if (ctx->private_outstanding_work > 0) { sched->outstanding_work_.fetch_add( @@ -904,8 +844,7 @@ reactor_scheduler::do_one( // Handle reactor sentinel — time to poll for I/O if (op == &task_op_) { - bool more_handlers = - !completed_ops_.empty() || (ctx && !ctx->private_queue.empty()); + bool more_handlers = !completed_ops_.empty(); if (!more_handlers && (outstanding_work_.load(std::memory_order_acquire) == 0 || @@ -968,10 +907,6 @@ reactor_scheduler::do_one( return 1; } - // Try private queue before blocking - if (reactor_drain_private_queue(ctx, outstanding_work_, completed_ops_)) - continue; - if (outstanding_work_.load(std::memory_order_acquire) == 0 || timeout_us == 0) return 0; diff --git a/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp b/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp index 428348f8e..7706a9e6a 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_stream_socket.hpp @@ -301,23 +301,6 @@ class reactor_stream_socket return nullptr; } - template - bool* op_to_cancel_flag(Op& op) noexcept - { - if (&op == static_cast(&conn_)) - return &this->desc_state_.connect_cancel_pending; - if (&op == static_cast(&rd_)) - return &this->desc_state_.read_cancel_pending; - if (&op == static_cast(&wr_)) - return &this->desc_state_.write_cancel_pending; - if (&op == static_cast(&wait_rd_)) - return &this->desc_state_.wait_read_cancel_pending; - if (&op == static_cast(&wait_wr_)) - return &this->desc_state_.wait_write_cancel_pending; - if (&op == static_cast(&wait_er_)) - return &this->desc_state_.wait_error_cancel_pending; - return nullptr; - } template void for_each_op(Fn fn) noexcept @@ -413,8 +396,7 @@ reactor_stream_socketshared_from_this(); this->register_op( - op, this->desc_state_.connect_op, this->desc_state_.write_ready, - this->desc_state_.connect_cancel_pending, true); + op, this->desc_state_.connect_op, this->desc_state_.write_ready, true); return std::noop_coroutine(); } @@ -537,8 +519,7 @@ reactor_stream_socketshared_from_this(); this->register_op( - op, this->desc_state_.read_op, this->desc_state_.read_ready, - this->desc_state_.read_cancel_pending); + op, this->desc_state_.read_op, this->desc_state_.read_ready); return std::noop_coroutine(); } @@ -651,8 +632,7 @@ reactor_stream_socketshared_from_this(); this->register_op( - op, this->desc_state_.write_op, this->desc_state_.write_ready, - this->desc_state_.write_cancel_pending, true); + op, this->desc_state_.write_op, this->desc_state_.write_ready, true); return std::noop_coroutine(); } @@ -678,28 +658,24 @@ reactor_stream_socketdesc_state_.wait_read_op; - cancel_flag_ptr = &this->desc_state_.wait_read_cancel_pending; event = reactor_event_read; } else if (w == wait_type::write) { op_ptr = &wait_wr_; desc_slot_ptr = &this->desc_state_.wait_write_op; - cancel_flag_ptr = &this->desc_state_.wait_write_cancel_pending; event = reactor_event_write; } else // wait_type::error { op_ptr = &wait_er_; desc_slot_ptr = &this->desc_state_.wait_error_op; - cancel_flag_ptr = &this->desc_state_.wait_error_cancel_pending; event = reactor_event_error; } @@ -746,7 +722,7 @@ reactor_stream_socketregister_op(op, *desc_slot_ptr, force_probe, *cancel_flag_ptr, + this->register_op(op, *desc_slot_ptr, force_probe, event == reactor_event_write); return std::noop_coroutine(); } diff --git a/test/unit/reactor_paths.cpp b/test/unit/reactor_paths.cpp index 1bccabfdd..f1d8fccfc 100644 --- a/test/unit/reactor_paths.cpp +++ b/test/unit/reactor_paths.cpp @@ -834,7 +834,7 @@ struct reactor_paths_test // Stop-token cancel of a parked wait(read). Exercises cancel_single_op // via the per-op canceller (not socket.cancel()), which dispatches - // through op_to_desc_slot/op_to_cancel_flag for the wait_rd_ slot. + // through op_to_desc_slot for the wait_rd_ slot. void testStopTokenWaitRead() { io_context ioc(Backend); From 71d7dfd52b30f8c890bdaf410df39575024c9fd4 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 20:17:52 +0200 Subject: [PATCH 14/29] test(tls): drive the driver's flush latch, alert flush, and buffer edges --- test/unit/openssl_stream.cpp | 185 +++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/test/unit/openssl_stream.cpp b/test/unit/openssl_stream.cpp index b2c62da73..5dbe79014 100644 --- a/test/unit/openssl_stream.cpp +++ b/test/unit/openssl_stream.cpp @@ -12,6 +12,8 @@ // Test that header file is self-contained. #include +#include + #include "tls_stream_tests.hpp" #ifdef BOOST_COROSIO_HAS_OPENSSL @@ -252,6 +254,185 @@ struct openssl_stream_test make_stream); } + // Transport wrapper whose writes fail on demand; reads pass + // through. Drives the driver's deferred-flush-error latch. + struct flush_fail_stream + { + corosio::test::mocket* m_; + bool fail_writes_ = false; + std::error_code inject_ec_{}; + + template + capy::io_task read_some(MutableBufferSequence buffers) + { + co_return co_await m_->read_some(buffers); + } + + template + capy::io_task write_some(ConstBufferSequence buffers) + { + if (fail_writes_) + co_return {inject_ec_, 0}; + co_return co_await m_->write_some(buffers); + } + }; + + // Handshake a client/server pair over mockets, wrapping the client + // transport, then hand control to `scenario`. + template + static void runWrappedSession(Wrapper& w, Scenario scenario) + { + io_context ioc; + auto [m1, m2] = corosio::test::make_mocket_pair(ioc); + w.m_ = &m1; + + auto client_ctx = test::make_client_context(); + auto server_ctx = test::make_server_context(); + auto client = openssl_stream(&w, client_ctx); + auto server = openssl_stream(&m2, server_ctx); + + { + auto hs_client = [&]() -> capy::task<> { + auto [ec] = co_await client.handshake(tls_role::client); + BOOST_TEST(!ec); + }; + auto hs_server = [&]() -> capy::task<> { + auto [ec] = co_await server.handshake(tls_role::server); + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(hs_client()); + capy::run_async(ioc.get_executor())(hs_server()); + ioc.run(); + ioc.restart(); + } + scenario(ioc, client, server, m1, m2); + } + + void testZeroLengthBufferInSequence() + { + flush_fail_stream w{}; + runWrappedSession(w, [](io_context& ioc, auto& client, auto& server, + auto&, auto&) { + char rx[16] = {}; + bool wrote = false, read = false; + auto writer = [&]() -> capy::task<> { + std::array bufs = { + capy::const_buffer("", 0), capy::const_buffer("hey", 3)}; + auto [ec, n] = co_await client.write_some(bufs); + wrote = !ec && n == 3; + }; + auto reader = [&]() -> capy::task<> { + std::array bufs = { + capy::mutable_buffer(rx, 0), + capy::mutable_buffer(rx, sizeof(rx))}; + auto [ec, n] = co_await server.read_some(bufs); + read = !ec && n == 3; + }; + capy::run_async(ioc.get_executor())(writer()); + capy::run_async(ioc.get_executor())(reader()); + ioc.run(); + BOOST_TEST(wrote); + BOOST_TEST(read); + BOOST_TEST_EQ(std::string_view(rx, 3), "hey"); + }); + } + + void testWriteFlushErrorIsLatched() + { + flush_fail_stream w{}; + w.inject_ec_ = std::make_error_code(std::errc::connection_reset); + runWrappedSession(w, [&w](io_context& ioc, auto& client, auto&, + auto&, auto&) { + // The engine accepts the whole payload, so the failed + // transport flush must be deferred to the next operation, + // not conflated with this one's success. + bool first_ok = false; + std::error_code second_ec; + auto writer = [&]() -> capy::task<> { + w.fail_writes_ = true; + auto [ec, n] = co_await client.write_some( + capy::const_buffer("hello", 5)); + first_ok = !ec && n == 5; + auto [ec2, n2] = + co_await client.write_some(capy::const_buffer("x", 1)); + std::ignore = n2; + second_ec = ec2; + }; + capy::run_async(ioc.get_executor())(writer()); + ioc.run(); + BOOST_TEST(first_ok); + BOOST_TEST(second_ec == + std::make_error_code(std::errc::connection_reset)); + }); + } + + void testCorruptRecordFailsReadAndShutdown() + { + flush_fail_stream w{}; + runWrappedSession(w, [](io_context& ioc, auto& client, auto&, + auto&, auto& m2) { + // Raw junk on the transport: the engine rejects the record + // and queues a fatal alert the driver must still flush. + char junk[64]; + for (std::size_t i = 0; i < sizeof(junk); ++i) + junk[i] = static_cast(0x5a ^ i); + char rx[16]; + std::error_code rec; + bool shut_done = false; + auto peer = [&]() -> capy::task<> { + auto [ec, n] = co_await m2.write_some( + capy::const_buffer(junk, sizeof(junk))); + std::ignore = ec; + std::ignore = n; + }; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await client.read_some(capy::mutable_buffer(rx, sizeof(rx))); + std::ignore = n; + rec = ec; + auto [sec] = co_await client.shutdown(); + std::ignore = sec; + shut_done = true; + }; + capy::run_async(ioc.get_executor())(peer()); + capy::run_async(ioc.get_executor())(reader()); + ioc.run(); + BOOST_TEST(!!rec); + BOOST_TEST(shut_done); + }); + } + + void testOversizedWriteRoundTrips() + { + flush_fail_stream w{}; + runWrappedSession(w, [](io_context& ioc, auto& client, auto& server, + auto&, auto&) { + // Larger than the engine's staging capacity: the driver + // must flush and retry until the payload is accepted. + std::string const payload(64 * 1024, 'q'); + std::string rx; + bool wrote = false, read = false; + auto writer = [&]() -> capy::task<> { + auto [ec, n] = co_await capy::write(client, + capy::const_buffer(payload.data(), payload.size())); + wrote = !ec && n == payload.size(); + }; + auto reader = [&]() -> capy::task<> { + rx.resize(payload.size()); + auto [ec, n] = co_await capy::read(server, + capy::mutable_buffer(rx.data(), rx.size())); + read = !ec && n == rx.size(); + }; + capy::run_async(ioc.get_executor())(writer()); + capy::run_async(ioc.get_executor())(reader()); + ioc.run(); + BOOST_TEST(wrote); + BOOST_TEST(read); + BOOST_TEST(rx == payload); + }); + } + + void run() { test::testIoBeforeHandshake(make_stream); @@ -308,6 +489,10 @@ struct openssl_stream_test testBadTls13SuitesFailsHandshake(); testBadCrlWithRevocationFailsHandshake(); testDuplicateCaTolerated(); + testZeroLengthBufferInSequence(); + testWriteFlushErrorIsLatched(); + testCorruptRecordFailsReadAndShutdown(); + testOversizedWriteRoundTrips(); test::testReset(make_stream, cert_modes); test::testResetViaHandshake(make_stream, cert_modes); From 5c4cd0941cfa8013bc30368297b0de3e9391ebff Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 20:28:33 +0200 Subject: [PATCH 15/29] test(reactor): batch-dispatch a descriptor event and contend bounded runners --- test/unit/mt_reactor.cpp | 160 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 test/unit/mt_reactor.cpp diff --git a/test/unit/mt_reactor.cpp b/test/unit/mt_reactor.cpp new file mode 100644 index 000000000..5af0abf97 --- /dev/null +++ b/test/unit/mt_reactor.cpp @@ -0,0 +1,160 @@ +// +// 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 +// + +// Reactor-scheduler paths that need batched or contended dispatch: a +// descriptor event completing several parked ops at once, and a +// cond-parked follower woken by work posted from a foreign thread. +// Durations only bound blocking; nothing asserts elapsed time. + +#include + +#if BOOST_COROSIO_HAS_EPOLL || BOOST_COROSIO_HAS_KQUEUE || \ + BOOST_COROSIO_HAS_SELECT + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include "context.hpp" +#include "test_suite.hpp" + +namespace boost::corosio { + +template +struct mt_reactor_test +{ + void + testEventCompletesBatchedOps() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + // A read and a readiness wait park on the same descriptor, so + // one readable event dispatches both in a single batch. Two + // bytes arrive and the read takes one, so readiness survives + // the read and the wait can complete. + char buf[1]; + std::error_code rec, wec; + int done = 0; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + ++done; + }; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::read); + wec = ec; + ++done; + }; + auto trip = [&]() -> capy::task<> { + char cc[2] = {'z', 'z'}; + std::ignore = ::send( + static_cast(s2.native_handle()), cc, 2, MSG_NOSIGNAL); + co_return; + }; + capy::run_async(ex)(reader()); + capy::run_async(ex)(waiter()); + capy::run_async(ex)(trip()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(!rec); + BOOST_TEST(!wec); + } + + void + testForeignPostWakesParkedFollower() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + // The parked read keeps outstanding work alive so both runner + // threads stay inside the scheduler: one leads in the reactor, + // the other parks in the signal wait. Each foreign post then + // has a parked follower to wake. + char buf[4]; + bool resumed = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = ec; + std::ignore = n; + resumed = true; + }; + capy::run_async(ex)(reader()); + + std::atomic entered{0}; + std::atomic nops{0}; + auto slice = [&] { + entered.fetch_add(1); + for (int i = 0; i < 100 && nops.load() < 20; ++i) + std::ignore = ioc.run_one_for(std::chrono::milliseconds(10)); + }; + std::thread ra(slice), rb(slice); + + while (entered.load() < 2) + { + } + for (int i = 0; i < 20; ++i) + { + // The counter travels as a parameter: a loop-scoped + // closure would die before the runner threads execute the + // frame that references it. + capy::run_async(ex)([](std::atomic* n) -> capy::task<> { + n->fetch_add(1); + co_return; + }(&nops)); + } + ra.join(); + rb.join(); + BOOST_TEST_EQ(nops.load(), 20); + + s1.cancel(); + ioc.restart(); + ioc.run(); + BOOST_TEST(resumed); + } + + void + run() + { + testEventCompletesBatchedOps(); + testForeignPostWakesParkedFollower(); + } +}; + +COROSIO_REACTOR_BACKEND_TESTS(mt_reactor_test, "boost.corosio.mt_reactor") + +} // namespace boost::corosio + +#endif From ab7461cab02449f9bf97e11c15bd4858e61e01b6 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 20:28:52 +0200 Subject: [PATCH 16/29] test(iocp): cancel, tear down, and validate the paths only Windows runs --- test/unit/iocp_paths.cpp | 470 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 test/unit/iocp_paths.cpp diff --git a/test/unit/iocp_paths.cpp b/test/unit/iocp_paths.cpp new file mode 100644 index 000000000..081a37fbc --- /dev/null +++ b/test/unit/iocp_paths.cpp @@ -0,0 +1,470 @@ +// +// 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 +// + +// IOCP paths no other suite drives: per-op cancellation hooks for the +// write/connect/wait directions, teardown with overlapped ops still in +// flight, assign validation, and the receive-direction shutdowns. + +#include + +#if BOOST_COROSIO_HAS_IOCP + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "context.hpp" +#include "test_suite.hpp" + +namespace boost::corosio { + +namespace { + +// A connected local-stream pair built through an acceptor, mirroring +// make_socket_pair; must not be called from inside a coroutine (it +// runs the context to completion). +std::pair +make_local_pair(io_context& ioc, test::temp_socket_dir const& tmp) +{ + local_stream_acceptor acc(ioc); + if (acc.open()) + throw std::runtime_error("acceptor open"); + if (acc.bind(local_endpoint(tmp.path()))) + throw std::runtime_error("acceptor bind"); + if (acc.listen()) + throw std::runtime_error("acceptor listen"); + + local_stream_socket client(ioc), server(ioc); + if (client.open()) + throw std::runtime_error("client open"); + + auto ex = ioc.get_executor(); + auto connector = [](local_stream_socket& c, + corosio::local_endpoint ep) -> capy::task<> { + auto [ec] = co_await c.connect(ep); + BOOST_TEST(!ec); + }; + auto accepter = [](local_stream_acceptor& a, + local_stream_socket& s) -> capy::task<> { + auto [ec] = co_await a.accept(s); + BOOST_TEST(!ec); + }; + capy::run_async(ex)(connector(client, local_endpoint(tmp.path()))); + capy::run_async(ex)(accepter(acc, server)); + ioc.run(); + ioc.restart(); + acc.close(); + return {std::move(server), std::move(client)}; +} + +struct temp_file +{ + std::filesystem::path path; + + explicit temp_file(std::string_view contents) + { + static int counter = 0; + path = std::filesystem::temp_directory_path() / + ("corosio_iocp_paths_" + std::to_string(counter++)); + std::ofstream(path) << contents; + } + + ~temp_file() + { + std::error_code ec; + std::filesystem::remove(path, ec); + } + + temp_file(temp_file const&) = delete; + temp_file& operator=(temp_file const&) = delete; +}; + +} // namespace + +struct iocp_paths_test +{ + void testStopCancelsLocalStreamOps() + { + io_context ioc(iocp); + auto ex = ioc.get_executor(); + test::temp_socket_dir tmp; + auto [s1, s2] = make_local_pair(ioc, tmp); + + // SO_SNDBUF of zero makes every overlapped send pend until the + // peer reads, so the write is reliably in flight when the stop + // arrives. + BOOST_TEST_NO_THROW( + s1.set_option(socket_option::send_buffer_size(0))); + + std::stop_source ss; + char big[65536] = {}; + char buf[8]; + std::error_code wec, wtec, rec; + int done = 0; + auto writer = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.write_some(capy::const_buffer(big, sizeof(big))); + std::ignore = n; + wec = ec; + ++done; + }; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await s1.wait(wait_type::read); + wtec = ec; + ++done; + }; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + ++done; + }; + auto stopper = [&]() -> capy::task<> { + ss.request_stop(); + co_return; + }; + capy::run_async(ex, ss.get_token())(writer()); + capy::run_async(ex, ss.get_token())(waiter()); + capy::run_async(ex, ss.get_token())(reader()); + capy::run_async(ex)(stopper()); + ioc.run(); + + BOOST_TEST_EQ(done, 3); + BOOST_TEST(wec == capy::cond::canceled); + BOOST_TEST(wtec == capy::cond::canceled); + BOOST_TEST(rec == capy::cond::canceled); + } + + void testStopCancelsAcceptorWaits() + { + io_context ioc(iocp); + auto ex = ioc.get_executor(); + + tcp_acceptor tacc(ioc); + BOOST_TEST(!tacc.open(tcp::v4())); + BOOST_TEST(!tacc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!tacc.listen()); + + test::temp_socket_dir tmp; + local_stream_acceptor lacc(ioc); + BOOST_TEST(!lacc.open()); + BOOST_TEST(!lacc.bind(local_endpoint(tmp.path()))); + BOOST_TEST(!lacc.listen()); + + std::stop_source ss; + std::error_code tec, lec; + int done = 0; + auto twaiter = [&]() -> capy::task<> { + auto [ec] = co_await tacc.wait(wait_type::read); + tec = ec; + ++done; + }; + auto lwaiter = [&]() -> capy::task<> { + auto [ec] = co_await lacc.wait(wait_type::read); + lec = ec; + ++done; + }; + auto stopper = [&]() -> capy::task<> { + ss.request_stop(); + co_return; + }; + capy::run_async(ex, ss.get_token())(twaiter()); + capy::run_async(ex, ss.get_token())(lwaiter()); + capy::run_async(ex)(stopper()); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + BOOST_TEST(tec == capy::cond::canceled); + BOOST_TEST(lec == capy::cond::canceled); + } + + void testAssignValidation() + { + io_context ioc(iocp); + auto const invalid = static_cast(~0ull); + + tcp_socket t(ioc); + BOOST_TEST(!t.open(tcp::v4())); + BOOST_TEST(!!t.assign(invalid)); + BOOST_TEST( + t.assign(t.native_handle()) == + std::make_error_code(std::errc::invalid_argument)); + BOOST_TEST(t.is_open()); + + // A datagram socket is the wrong type for a TCP stream slot. + udp_socket u(ioc); + BOOST_TEST(!u.open(udp::v4())); + auto ufd = u.release(); + BOOST_TEST(!!t.assign(ufd)); + ::closesocket(static_cast(ufd)); + + udp_socket u2(ioc); + BOOST_TEST(!u2.open(udp::v4())); + BOOST_TEST(!!u2.assign(invalid)); + BOOST_TEST( + u2.assign(u2.native_handle()) == + std::make_error_code(std::errc::invalid_argument)); + + // An AF_INET socket cannot back an AF_UNIX acceptor. + test::temp_socket_dir tmp; + local_stream_acceptor lacc(ioc); + BOOST_TEST(!lacc.open()); + tcp_socket t2(ioc); + BOOST_TEST(!t2.open(tcp::v4())); + auto tfd = t2.release(); + BOOST_TEST(!!lacc.assign(tfd)); + ::closesocket(static_cast(tfd)); + + BOOST_TEST( + lacc.assign(lacc.native_handle()) == + std::make_error_code(std::errc::invalid_argument)); + } + + void testShutdownReceiveVariants() + { + io_context ioc(iocp); + auto ex = ioc.get_executor(); + + test::temp_socket_dir tmp; + auto [l1, l2] = make_local_pair(ioc, tmp); + BOOST_TEST(!l1.shutdown(shutdown_receive)); + + udp_socket u1(ioc), u2(ioc); + BOOST_TEST(!u1.open(udp::v4())); + BOOST_TEST(!u2.open(udp::v4())); + BOOST_TEST(!u1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!u2.bind(endpoint(ipv4_address::loopback(), 0))); + + bool ok = false; + auto task = [&]() -> capy::task<> { + auto [cec] = co_await u1.connect(u2.local_endpoint()); + if (!cec && !u1.shutdown(shutdown_receive)) + ok = true; + }; + capy::run_async(ex)(task()); + ioc.run(); + BOOST_TEST(ok); + } + + void testZeroLengthUdpReceive() + { + io_context ioc(iocp); + auto ex = ioc.get_executor(); + + udp_socket u1(ioc), u2(ioc); + BOOST_TEST(!u1.open(udp::v4())); + BOOST_TEST(!u2.open(udp::v4())); + BOOST_TEST(!u1.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!u2.bind(endpoint(ipv4_address::loopback(), 0))); + + bool done = false; + std::error_code rec; + std::size_t rn = 99; + auto task = [&]() -> capy::task<> { + auto [cec] = co_await u1.connect(u2.local_endpoint()); + std::ignore = cec; + auto [ec, n] = co_await u1.recv(capy::mutable_buffer(nullptr, 0)); + rec = ec; + rn = n; + done = true; + }; + capy::run_async(ex)(task()); + ioc.run(); + + BOOST_TEST(done); + BOOST_TEST(!rec); + BOOST_TEST_EQ(rn, 0u); + } + + void testResolverEmptyInputs() + { + io_context ioc(iocp); + auto ex = ioc.get_executor(); + resolver r(ioc); + + bool done = false; + std::error_code fec; + auto task = [&]() -> capy::task<> { + auto [ec, res] = co_await r.resolve("", ""); + std::ignore = res; + fec = ec; + done = true; + }; + capy::run_async(ex)(task()); + ioc.run(); + + BOOST_TEST(done); + BOOST_TEST(!!fec); + } + + void testReleasedAcceptorAccessors() + { + io_context ioc(iocp); + auto const invalid = static_cast(~0ull); + + test::temp_socket_dir tmp; + local_stream_acceptor acc(ioc); + BOOST_TEST(!acc.open()); + BOOST_TEST(!acc.bind(local_endpoint(tmp.path()))); + BOOST_TEST(!acc.listen()); + + auto fd = acc.release(); + BOOST_TEST(fd != invalid); + ::closesocket(static_cast(fd)); + + BOOST_TEST(acc.native_handle() == invalid); + BOOST_TEST_THROWS( + acc.set_option(socket_option::reuse_address(true)), + std::system_error); + BOOST_TEST_THROWS( + std::ignore = acc.get_option(), + std::system_error); + } + + void testTruncateWithoutCreate() + { + temp_file tmp("some existing content"); + io_context ioc(iocp); + random_access_file f(ioc); + BOOST_TEST( + !f.open(tmp.path, file_base::write_only | file_base::truncate)); + BOOST_TEST_EQ(f.size(), 0u); + f.close(); + } + +#if !COROSIO_TEST_HAS_ASAN + // These abandon parked coroutine frames by design; see context.hpp. + + void testDestroyWithParkedSocketOps() + { + int resumed = 0; + { + io_context ioc(iocp); + auto ex = ioc.get_executor(); + + udp_socket u(ioc); + std::ignore = u.open(udp::v4()); + std::ignore = u.bind(endpoint(ipv4_address::loopback(), 0)); + + tcp_acceptor tacc(ioc); + std::ignore = tacc.open(tcp::v4()); + std::ignore = tacc.bind(endpoint(ipv4_address::loopback(), 0)); + std::ignore = tacc.listen(); + + char buf[8]; + endpoint src; + auto urecv = [&]() -> capy::task<> { + std::ignore = co_await u.recv_from( + capy::mutable_buffer(buf, sizeof(buf)), src); + ++resumed; + }; + auto uwait = [&]() -> capy::task<> { + std::ignore = co_await u.wait(wait_type::read); + ++resumed; + }; + auto await_ = [&]() -> capy::task<> { + std::ignore = co_await tacc.wait(wait_type::read); + ++resumed; + }; + capy::run_async(ex)(urecv()); + capy::run_async(ex)(uwait()); + capy::run_async(ex)(await_()); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + } + BOOST_TEST_EQ(resumed, 0); + } + + void testDestroyWithParkedLocalOps() + { + int resumed = 0; + test::temp_socket_dir tmp; + { + io_context ioc(iocp); + auto ex = ioc.get_executor(); + auto [s1, s2] = make_local_pair(ioc, tmp); + + test::temp_socket_dir tmp2; + local_stream_acceptor lacc(ioc); + std::ignore = lacc.open(); + std::ignore = lacc.bind(local_endpoint(tmp2.path())); + std::ignore = lacc.listen(); + + auto reader = [](local_stream_socket s, int& count) + -> capy::task<> { + char b[8]; + std::ignore = + co_await s.read_some(capy::mutable_buffer(b, sizeof(b))); + ++count; + }(std::move(s1), resumed); + auto lwait = [&]() -> capy::task<> { + std::ignore = co_await lacc.wait(wait_type::read); + ++resumed; + }; + capy::run_async(ex)(std::move(reader)); + capy::run_async(ex)(lwait()); + std::ignore = ioc.run_one(); + std::ignore = ioc.run_one(); + } + BOOST_TEST_EQ(resumed, 0); + } +#endif // !COROSIO_TEST_HAS_ASAN + + void run() + { + testStopCancelsLocalStreamOps(); + testStopCancelsAcceptorWaits(); + testAssignValidation(); + testShutdownReceiveVariants(); + testZeroLengthUdpReceive(); + testResolverEmptyInputs(); + testReleasedAcceptorAccessors(); + testTruncateWithoutCreate(); +#if !COROSIO_TEST_HAS_ASAN + testDestroyWithParkedSocketOps(); + testDestroyWithParkedLocalOps(); +#endif + } +}; + +TEST_SUITE(iocp_paths_test, "boost.corosio.iocp_paths"); + +} // namespace boost::corosio + +#endif // BOOST_COROSIO_HAS_IOCP From 9d008609b09f1a472af7a5ec91b0dc110d1679e1 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 21:55:38 +0200 Subject: [PATCH 17/29] test(fault): add an allocation-failure entry and drive the bad_alloc arms A cpp_new arm makes the nth allocation on the armed thread report bad_alloc through replaced global allocation functions; no interposition is needed, so the entry is live on every platform. Covers the pool's spawn-allocation recovery, its refusal propagation through the file service, and select's registry-growth ENOMEM arm. The timer rearm and server launch-frame guards sit behind pooled or boundary allocations no deterministic ordinal reaches; their sweeps exercise robustness and the arms stay on the residual list. --- test/unit/fault/CMakeLists.txt | 4 +- test/unit/fault/Jamfile | 15 +- test/unit/fault/alloc_faults.cpp | 389 +++++++++++++++++++++++++++++++ test/unit/fault/fault.hpp | 7 +- test/unit/fault/fault_alloc.cpp | 194 +++++++++++++++ test/unit/fault/fault_posix.cpp | 3 + test/unit/fault/fault_win.cpp | 3 + 7 files changed, 606 insertions(+), 9 deletions(-) create mode 100644 test/unit/fault/alloc_faults.cpp create mode 100644 test/unit/fault/fault_alloc.cpp diff --git a/test/unit/fault/CMakeLists.txt b/test/unit/fault/CMakeLists.txt index afd08c48d..184cb0058 100644 --- a/test/unit/fault/CMakeLists.txt +++ b/test/unit/fault/CMakeLists.txt @@ -39,12 +39,14 @@ if(WIN32) # 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_alloc.cpp ${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 - ${CMAKE_CURRENT_SOURCE_DIR}/iocp_dissociate_faults.cpp) + ${CMAKE_CURRENT_SOURCE_DIR}/iocp_dissociate_faults.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/alloc_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 0d24b99a0..c95c2e11a 100644 --- a/test/unit/fault/Jamfile +++ b/test/unit/fault/Jamfile @@ -69,32 +69,35 @@ local gcov-dump = [ check-target-builds gcov_dump_check "gcov dump linkable" # 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 hooks = fault_arm.cpp fault_posix.cpp fault_alloc.cpp ; local tests ; local uring-hook ; if [ os.name ] = NT { - hooks = fault_arm.cpp fault_win.cpp ; + hooks = fault_arm.cpp fault_win.cpp fault_alloc.cpp ; tests = self_test.cpp win_faults.cpp iocp_faults.cpp - iocp_dissociate_faults.cpp ; + iocp_dissociate_faults.cpp alloc_faults.cpp ; } else if [ os.name ] = MACOSX { tests = self_test.cpp posix_faults.cpp select_faults.cpp kqueue_faults.cpp reactor_faults.cpp - signal_pipe_faults.cpp signal_sigaction_faults.cpp ; + signal_pipe_faults.cpp signal_sigaction_faults.cpp + alloc_faults.cpp ; } else if [ os.name ] = FREEBSD { tests = self_test.cpp posix_faults.cpp select_faults.cpp kqueue_faults.cpp reactor_faults.cpp - signal_pipe_faults.cpp signal_sigaction_faults.cpp ; + signal_pipe_faults.cpp signal_sigaction_faults.cpp + alloc_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 - signal_pipe_faults.cpp signal_sigaction_faults.cpp ; + signal_pipe_faults.cpp signal_sigaction_faults.cpp + alloc_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/alloc_faults.cpp b/test/unit/fault/alloc_faults.cpp new file mode 100644 index 000000000..c1fffacd0 --- /dev/null +++ b/test/unit/fault/alloc_faults.cpp @@ -0,0 +1,389 @@ +// +// 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 +// + +// Allocation-failure recovery arms. Each scenario runs once per +// allocation ordinal in a forked child, so every `new` in the window +// takes the fault on some pass; an ordinal that lands on an unguarded +// site may abort that child, which the sweep tolerates — the guarded +// arms record their coverage on the passes that reach them, and the +// parent asserts only on the unarmed control run. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fault.hpp" +#include "fault_test_utils.hpp" + +#include + +#include "test_suite.hpp" + +#if !defined(_WIN32) +#include +#include +#endif + +namespace boost::corosio { + +namespace { + +using test::fault::fault_scope; +using test::fault::sys; + +#if !defined(_WIN32) + +// Assert the unarmed control run, then run `body` once per allocation +// ordinal in [1, limit], each pass in its own forked child with that +// ordinal armed inside `body`'s armed window. A child that aborts on +// an unguarded site is tolerated; coverage from every child is flushed +// before it exits. `body` receives the ordinal to arm, 0 for the +// unarmed control. +template +void +alloc_sweep(int limit, F body) +{ + BOOST_TEST(body(0u)); + for (int nth = 1; nth <= limit; ++nth) + { + pid_t pid = ::fork(); + BOOST_TEST(pid >= 0); + if (pid < 0) + return; + if (pid == 0) + { + bool const ok = body(static_cast(nth)); + test::fault::flush_coverage_counters(); + std::_Exit(ok ? 0 : 1); + } + int status = 0; + ::waitpid(pid, &status, 0); + } +} + +// Arm `cpp_new` for the enclosing scope when `nth` is nonzero; inert +// for the control run. +struct maybe_arm +{ + std::optional scope; + + explicit maybe_arm(unsigned nth) + { + if (nth != 0) + scope.emplace(sys::cpp_new, 0, nth); + } +}; + +#endif // !_WIN32 + +} // namespace + +struct alloc_fault_test +{ + void testArmFailsAllocation() + { + // Through volatile function pointers: an optimizer may elide a + // plain unused `new` outright (allocation elision), leaving the + // arm live to detonate on some later allocation. + void* (*volatile op_new)(std::size_t) = + static_cast(&::operator new); + void* (*volatile op_new_nt)(std::size_t, std::nothrow_t const&) = + static_cast( + &::operator new); + + // Valgrind (and some sanitizers) replace operator new with their + // own, which shadows this harness's replacement, so the arm can + // never fire. Probe once with the armed nothrow form: a non-null + // result means the replacement is bypassed, and the arm-firing + // assertions below would spuriously fail — skip them. + { + fault_scope probe(sys::cpp_new, 0); + if (void* p = op_new_nt(1, std::nothrow)) + { + ::operator delete(p); + return; + } + } + + { + fault_scope f(sys::cpp_new, 0); + BOOST_TEST_THROWS(std::ignore = op_new(1), std::bad_alloc); + BOOST_TEST(f.fired()); + } + { + fault_scope g(sys::cpp_new, 0); + void* p = op_new_nt(1, std::nothrow); + BOOST_TEST(p == nullptr); + BOOST_TEST(g.fired()); + if (p != nullptr) + ::operator delete(p); + } + + // Disarmed again: allocation works and the scope stays quiet. + std::unique_ptr q(new char('x')); + BOOST_TEST(*q == 'x'); + } + +#if !defined(_WIN32) + + // A capped-slice clock delay re-publishes through rearm_wait on + // every wake; a churn task keeps the timer heap growing so some + // rearm lands on a capacity boundary and takes the armed failure, + // which must finish that delay with an error instead of stranding + // the frame. + void testTimerRearmRecovery() + { + struct capped_traits + { + static std::chrono::system_clock::duration + to_wait_duration(std::chrono::system_clock::duration d) + { + return (std::min)(d, + std::chrono::system_clock::duration( + std::chrono::milliseconds(2))); + } + }; + + alloc_sweep(32, [](unsigned nth) { + io_context ioc; + auto ex = ioc.get_executor(); + int done = 0; + + auto repeater = [&]() -> capy::task<> { + std::ignore = co_await corosio::delay( + std::chrono::system_clock::now() + + std::chrono::milliseconds(40)); + ++done; + }; + auto churn = [&]() -> capy::task<> { + for (int i = 0; i < 16; ++i) + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(1)); + }; + for (int i = 0; i < 3; ++i) + capy::run_async(ex)(repeater()); + for (int i = 0; i < 6; ++i) + capy::run_async(ex)(churn()); + + maybe_arm arm(nth); + ioc.run(); + return done == 3; + }); + } + + // select's descriptor registry: a failed growth reports ENOMEM to + // the registering operation instead of corrupting the registry. + void testSelectRegisterRecovery() + { +#if BOOST_COROSIO_HAS_SELECT + alloc_sweep(8, [](unsigned nth) { + io_context ioc(select); + auto ex = ioc.get_executor(); + // Warm the context and registry machinery before arming. + auto [w1, w2] = + test::make_socket_pair(ioc); + + // Registration happens inside open(): the registry's node + // allocation is one of the few in this window, and its + // failure must surface as ENOMEM from open() with the + // registry intact. + maybe_arm arm(nth); + udp_socket u(ioc); + auto oec = u.open(udp::v4()); + if (oec) + return oec == std::errc::not_enough_memory; + + char buf[4]; + bool completed = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await u.recv(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = ec; + std::ignore = n; + completed = true; + }; + auto canceller = [&]() -> capy::task<> { + u.cancel(); + co_return; + }; + capy::run_async(ex)(reader()); + capy::run_async(ex)(canceller()); + ioc.run(); + return completed; + }); +#endif + } + + // thread_pool spawn: an allocation failure while starting the + // first worker must surface as a refusal from post(), which the + // file service reports through the operation. + void testPoolSpawnRecovery() + { + auto const path = test::fault::temp_path("alloc_pool"); + { + std::ofstream(path) << "payload"; + } + alloc_sweep(12, [&path](unsigned nth) { + io_context ioc; + stream_file f(ioc); + if (f.open(path, file_base::read_only)) + return false; + char buf[8]; + bool completed = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = co_await f.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = ec; + std::ignore = n; + completed = true; + }; + + // The first post spawns the pool worker inside the armed + // window; a refused spawn must complete the read with the + // refusal, which still counts as completion. + maybe_arm arm(nth); + capy::run_async(ioc.get_executor())(reader()); + ioc.run(); + return completed; + }); + std::error_code ignored; + std::filesystem::remove(path, ignored); + } + + // tcp_server dispatch: a coroutine frame that fails to allocate + // must hand the worker back to the pool and keep serving. + void testServerLaunchRecovery() + { + alloc_sweep(24, [](unsigned nth) { + io_context ioc; + + class echo_worker : public tcp_server::worker_base + { + io_context& ctx_; + corosio::tcp_socket sock_; + + public: + std::atomic* count = nullptr; + + echo_worker(io_context& ctx, std::atomic* c) + : ctx_(ctx), sock_(ctx), count(c) + { + } + + corosio::tcp_socket& socket() override { return sock_; } + + void run(tcp_server::launcher launch) override + { + count->fetch_add(1); + launch(ctx_.get_executor(), + [](corosio::tcp_socket* s) -> capy::task<> { + s->close(); + co_return; + }(&sock_)); + } + }; + + std::atomic served{0}; + + class one_server : public tcp_server + { + public: + one_server(io_context& ctx, std::atomic* c) + : tcp_server(ctx, ctx.get_executor()) + { + std::vector> v; + v.push_back(std::make_unique(ctx, c)); + set_workers(std::move(v)); + } + }; + + one_server srv(ioc, &served); + if (srv.bind(endpoint(ipv4_address::loopback(), 0))) + return false; + auto port = srv.local_endpoint().port(); + srv.start(); + + auto driver = [](io_context* ctx, std::uint16_t p, + one_server* s, unsigned nth) -> capy::task<> { + { + tcp_socket c(*ctx); + std::ignore = c.open(); + [[maybe_unused]] auto [ec] = co_await c.connect( + endpoint(ipv4_address::loopback(), p)); + c.close(); + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(10)); + } + { + // The dispatch of this connection allocates the + // session frame inside the armed window. + maybe_arm arm(nth); + tcp_socket c(*ctx); + std::ignore = c.open(); + [[maybe_unused]] auto [ec] = co_await c.connect( + endpoint(ipv4_address::loopback(), p)); + std::ignore = co_await corosio::delay( + std::chrono::milliseconds(10)); + c.close(); + } + s->stop(); + }(&ioc, port, &srv, nth); + capy::run_async(ioc.get_executor())(std::move(driver)); + ioc.run(); + srv.join(); + return served.load() >= 1; + }); + } + +#endif // !_WIN32 + + void run() + { + testArmFailsAllocation(); +#if !defined(_WIN32) + testTimerRearmRecovery(); + testSelectRegisterRecovery(); + testPoolSpawnRecovery(); + testServerLaunchRecovery(); +#endif + } +}; + +TEST_SUITE(alloc_fault_test, "boost.corosio.fault.alloc"); + +} // namespace boost::corosio diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp index bdeda8d86..31cfbd5ab 100644 --- a/test/unit/fault/fault.hpp +++ b/test/unit/fault/fault.hpp @@ -30,7 +30,10 @@ namespace boost::corosio::test::fault { `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. + registrations included. `cpp_new` is not an OS symbol: it names the + global allocation functions the fault target replaces, so arming it + makes the nth `new` on the armed thread report `bad_alloc` (the + nothrow forms return null). */ enum class sys { @@ -45,7 +48,7 @@ enum class sys 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, + io_uring_wait_cqe_timeout, uring_sqe_full, cpp_new, WSASocketW, WSAConnect, WSARecv, WSASend, WSARecvFrom, WSASendTo, WSAPoll, WSAIoctl, WSAStartup, WSACleanup, closesocket, ioctlsocket, GetAddrInfoExW, GetAddrInfoExCancel, FreeAddrInfoExW, GetNameInfoW, diff --git a/test/unit/fault/fault_alloc.cpp b/test/unit/fault/fault_alloc.cpp new file mode 100644 index 000000000..a190cf2a9 --- /dev/null +++ b/test/unit/fault/fault_alloc.cpp @@ -0,0 +1,194 @@ +// +// 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 +// + +// Global allocation replacement consulting the `cpp_new` arm. Unlike +// the syscall shadows this needs no interposition machinery: replacing +// the global allocation functions in any translation unit of the +// program rebinds every `new` in the executable — and, on ELF and +// Mach-O, in the shared libraries it loads — so a test can fail the +// nth allocation of a scope and drive the library's `bad_alloc` +// recovery arms. The replacements forward to `malloc`, whose own +// failure still reports `bad_alloc` the standard way. + +#include "fault.hpp" +#include "fault_slot.hpp" + +#include +#include + +#if defined(_WIN32) +#include +#endif + +namespace { + +using boost::corosio::test::fault::should_fail; +using boost::corosio::test::fault::sys; + +void* +plain_alloc(std::size_t n) +{ + if (should_fail(sys::cpp_new)) + return nullptr; + // malloc(0) may return null without that being exhaustion. + return std::malloc(n ? n : 1); +} + +void* +aligned_alloc_impl(std::size_t n, std::size_t align) +{ + if (should_fail(sys::cpp_new)) + return nullptr; +#if defined(_WIN32) + return ::_aligned_malloc(n ? n : 1, align); +#else + void* p = nullptr; + if (::posix_memalign(&p, align, n ? n : 1) != 0) + return nullptr; + return p; +#endif +} + +void +aligned_free_impl(void* p) noexcept +{ +#if defined(_WIN32) + ::_aligned_free(p); +#else + std::free(p); +#endif +} + +} // namespace + +void* +operator new(std::size_t n) +{ + if (void* p = plain_alloc(n)) + return p; + throw std::bad_alloc{}; +} + +void* +operator new[](std::size_t n) +{ + return ::operator new(n); +} + +void* +operator new(std::size_t n, std::nothrow_t const&) noexcept +{ + return plain_alloc(n); +} + +void* +operator new[](std::size_t n, std::nothrow_t const&) noexcept +{ + return plain_alloc(n); +} + +void* +operator new(std::size_t n, std::align_val_t align) +{ + if (void* p = aligned_alloc_impl(n, static_cast(align))) + return p; + throw std::bad_alloc{}; +} + +void* +operator new[](std::size_t n, std::align_val_t align) +{ + return ::operator new(n, align); +} + +void* +operator new(std::size_t n, std::align_val_t align, + std::nothrow_t const&) noexcept +{ + return aligned_alloc_impl(n, static_cast(align)); +} + +void* +operator new[](std::size_t n, std::align_val_t align, + std::nothrow_t const&) noexcept +{ + return aligned_alloc_impl(n, static_cast(align)); +} + +void +operator delete(void* p) noexcept +{ + std::free(p); +} + +void +operator delete[](void* p) noexcept +{ + std::free(p); +} + +void +operator delete(void* p, std::size_t) noexcept +{ + std::free(p); +} + +void +operator delete[](void* p, std::size_t) noexcept +{ + std::free(p); +} + +void +operator delete(void* p, std::nothrow_t const&) noexcept +{ + std::free(p); +} + +void +operator delete[](void* p, std::nothrow_t const&) noexcept +{ + std::free(p); +} + +void +operator delete(void* p, std::align_val_t) noexcept +{ + aligned_free_impl(p); +} + +void +operator delete[](void* p, std::align_val_t) noexcept +{ + aligned_free_impl(p); +} + +void +operator delete(void* p, std::size_t, std::align_val_t) noexcept +{ + aligned_free_impl(p); +} + +void +operator delete[](void* p, std::size_t, std::align_val_t) noexcept +{ + aligned_free_impl(p); +} + +void +operator delete(void* p, std::align_val_t, std::nothrow_t const&) noexcept +{ + aligned_free_impl(p); +} + +void +operator delete[](void* p, std::align_val_t, std::nothrow_t const&) noexcept +{ + aligned_free_impl(p); +} diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp index 09e3c2905..11c349e9a 100644 --- a/test/unit/fault/fault_posix.cpp +++ b/test/unit/fault/fault_posix.cpp @@ -1057,6 +1057,9 @@ int const readback = [] bool hook_is_live(sys which) noexcept { + // Not a symbol: the replacement allocation functions always link. + if(which == sys::cpp_new) + return true; // 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) diff --git a/test/unit/fault/fault_win.cpp b/test/unit/fault/fault_win.cpp index a66a49e72..d0aebc9ef 100644 --- a/test/unit/fault/fault_win.cpp +++ b/test/unit/fault/fault_win.cpp @@ -841,6 +841,9 @@ bool corosio_is_shared() noexcept bool hook_is_live(sys which) noexcept { + // Not a symbol: the replacement allocation functions always link. + if(which == sys::cpp_new) + return true; switch(which) { // Substituted through the pointer WSAIoctl hands out. From 5c968b8f1718a777f3b4f3ba1fcb2736fea295d9 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Thu, 3 Sep 2026 21:58:03 +0200 Subject: [PATCH 18/29] test(fault): add the pthread_create entry and prove pool spawn refusal pthread_create reports through its return value, so the shadow hands back the armed error directly and std::thread construction surfaces it as system_error. The pool's first post then reports the refusal through the operation, leaves no latched state, and the next post spawns the worker normally. --- test/unit/fault/fault.hpp | 4 +- test/unit/fault/fault_posix.cpp | 16 +++++++- test/unit/fault/posix_faults.cpp | 68 ++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp index 31cfbd5ab..14df6de23 100644 --- a/test/unit/fault/fault.hpp +++ b/test/unit/fault/fault.hpp @@ -42,8 +42,8 @@ enum class sys 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, + fdatasync, posix_fadvise, unlink, sigaction, pthread_create, + getaddrinfo, freeaddrinfo, getnameinfo, gethostname, epoll_create1, epoll_ctl, epoll_wait, eventfd, timerfd_create, timerfd_settime, select, kqueue, kevent, kevent_register, io_uring_queue_init_params, io_uring_queue_exit, io_uring_submit, diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp index 11c349e9a..65be0922f 100644 --- a/test/unit/fault/fault_posix.cpp +++ b/test/unit/fault/fault_posix.cpp @@ -198,6 +198,19 @@ 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)) + +// pthread_create reports through its return value, not errno; the +// armed error published to errno is handed back directly, which is +// what turns std::thread's construction into a std::system_error. +extern "C" int pthread_create(pthread_t* t, pthread_attr_t const* a, + void* (*fn)(void*), void* arg) COROSIO_FAULT_NOTHROW +{ + COROSIO_FAULT_REAL(pthread_create, + int(*)(pthread_t*, pthread_attr_t const*, void* (*)(void*), void*)); + if(should_fail(sys::pthread_create)) + return errno ? errno : EAGAIN; + return real(t, a, fn, arg); +} COROSIO_FAULT_HOOK_NX(gethostname, int, -1, (char* n, size_t l), (n, l)) // Linux and FreeBSD both publish these; Darwin has neither, and @@ -701,7 +714,8 @@ namespace { 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(sigaction), COROSIO_FAULT_CENSUS(pthread_create), + COROSIO_FAULT_CENSUS(getaddrinfo), COROSIO_FAULT_CENSUS(freeaddrinfo), COROSIO_FAULT_CENSUS(getnameinfo), COROSIO_FAULT_CENSUS(gethostname), #if defined(__linux__) || defined(__FreeBSD__) diff --git a/test/unit/fault/posix_faults.cpp b/test/unit/fault/posix_faults.cpp index 7d77ce7ad..96fce3bb2 100644 --- a/test/unit/fault/posix_faults.cpp +++ b/test/unit/fault/posix_faults.cpp @@ -25,6 +25,9 @@ #include #include #include + +#include +#include #include #include #include @@ -671,4 +674,69 @@ struct reactor_acceptor_option_faults COROSIO_REACTOR_BACKEND_TESTS( reactor_acceptor_option_faults, "boost.corosio.fault.posix.acceptor_opts") +// The pool's first post spawns its workers; a refused thread must +// surface as a refusal from post(), reported through the operation, +// with the pool empty and the next post free to retry. +struct pool_thread_spawn_faults +{ + void testSpawnRefusalReachesOperation() + { + if(!hook_is_live(sys::pthread_create)) + { + test_suite::log << "pthread_create hook not live; skipping\n"; + return; + } + + auto path = temp_path("pool_spawn"); + { + std::ofstream out(path); + out << "payload"; + } + + io_context ioc; + stream_file f(ioc); + BOOST_TEST(!f.open(path, file_base::read_only)); + + char buf[8]; + std::error_code first_ec, second_ec; + int done = 0; + auto reader = [&](std::error_code& out) -> capy::task<> { + auto [ec, n] = + co_await f.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + out = ec; + ++done; + }; + + { + fault_scope fault(sys::pthread_create, EAGAIN); + capy::run_async(ioc.get_executor())(reader(first_ec)); + ioc.run(); + BOOST_TEST(fault.fired()); + } + ioc.restart(); + + // With the fault gone the next post spawns the worker and the + // read succeeds: the refusal left no latched state behind. + capy::run_async(ioc.get_executor())(reader(second_ec)); + ioc.run(); + + BOOST_TEST_EQ(done, 2); + // Condition comparison: which category the stdlib throws + // thread-creation failure with differs (libc++ uses system). + BOOST_TEST(first_ec == std::errc::resource_unavailable_try_again); + BOOST_TEST(!second_ec); + + std::remove(path.c_str()); + } + + void run() + { + testSpawnRefusalReachesOperation(); + } +}; + +TEST_SUITE(pool_thread_spawn_faults, "boost.corosio.fault.posix.pool_spawn"); + + } // boost::corosio::test::fault From 2ac31bce03281bdcce66bd760cc39c191fe39388 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 4 Sep 2026 22:55:29 +0200 Subject: [PATCH 19/29] test(fault): add OpenSSL entry points and drive the engine refusal arms Shadow the OpenSSL entry points the TLS engine calls -- the credential BIOs, context and session allocation, session reset, hostname pinning, and input staging -- with opaque C signatures that need no OpenSSL headers. Arming each drives the engine's refusal arms: the credential decoder, the poisoned-cache stickiness, the reset and hostname-setup refusals, and the staging-full retry. The entries are gated to Linux, where the coverage badge is measured; elsewhere the RTLD_NEXT lookup has nothing to bind and hook_is_live reports them not live so the TLS fault suite skips. --- test/unit/fault/CMakeLists.txt | 10 + test/unit/fault/fault.hpp | 5 + test/unit/fault/fault_posix.cpp | 126 ++++++++++- test/unit/fault/tls_faults.cpp | 375 ++++++++++++++++++++++++++++++++ 4 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 test/unit/fault/tls_faults.cpp diff --git a/test/unit/fault/CMakeLists.txt b/test/unit/fault/CMakeLists.txt index 184cb0058..a1160da87 100644 --- a/test/unit/fault/CMakeLists.txt +++ b/test/unit/fault/CMakeLists.txt @@ -66,6 +66,16 @@ target_link_libraries(boost_corosio_fault_tests PRIVATE target_include_directories(boost_corosio_fault_tests PRIVATE . .. ../../../ ../../../src/corosio) +# The OpenSSL shadows only have callers when the engine is linked; the +# TLS fault suite gates itself on this define. b2 never builds it: the +# coverage legs that publish the badges are all CMake. +if (NOT WIN32 AND OpenSSL_FOUND) + target_link_libraries(boost_corosio_fault_tests PRIVATE + boost_corosio_openssl) + target_compile_definitions(boost_corosio_fault_tests PRIVATE + COROSIO_FAULT_HAS_OPENSSL=1) +endif() + # 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 diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp index 14df6de23..aaca36d3e 100644 --- a/test/unit/fault/fault.hpp +++ b/test/unit/fault/fault.hpp @@ -49,6 +49,11 @@ enum class sys 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, cpp_new, + // OpenSSL entry points the TLS engine drives; live only when the + // process loads libssl/libcrypto. + BIO_new_mem_buf, BIO_new_bio_pair, BIO_read, BIO_nwrite0, + SSL_CTX_new, SSL_new, SSL_clear, SSL_set_session, SSL_get0_param, + X509_STORE_add_cert, X509_dup, X509_VERIFY_PARAM_set1_host, WSASocketW, WSAConnect, WSARecv, WSASend, WSARecvFrom, WSASendTo, WSAPoll, WSAIoctl, WSAStartup, WSACleanup, closesocket, ioctlsocket, GetAddrInfoExW, GetAddrInfoExCancel, FreeAddrInfoExW, GetNameInfoW, diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp index 65be0922f..78ba9f9eb 100644 --- a/test/unit/fault/fault_posix.cpp +++ b/test/unit/fault/fault_posix.cpp @@ -199,6 +199,117 @@ 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)) +// The OpenSSL shadows use opaque pointer signatures: the symbols have +// C linkage, so no OpenSSL headers are needed. They rely on ELF +// strong-symbol interposition, which only libcrypto's own callers reach +// on Linux; macOS's two-level namespace resolves those calls to +// libcrypto directly, so the shadows would never fire and their +// RTLD_NEXT lookup has nothing to bind. Gate them to Linux, where the +// coverage badge is measured; elsewhere hook_is_live reports them not +// live and the TLS fault suite skips. +#if defined(__linux__) +extern "C" void* BIO_new_mem_buf(void const* buf, int len) +{ + COROSIO_FAULT_REAL(BIO_new_mem_buf, void*(*)(void const*, int)); + if(should_fail(sys::BIO_new_mem_buf)) + return nullptr; + return real(buf, len); +} + +extern "C" int BIO_new_bio_pair(void** b1, std::size_t w1, void** b2, + std::size_t w2) +{ + COROSIO_FAULT_REAL(BIO_new_bio_pair, + int(*)(void**, std::size_t, void**, std::size_t)); + if(should_fail(sys::BIO_new_bio_pair)) + return 0; + return real(b1, w1, b2, w2); +} + +extern "C" void* SSL_CTX_new(void const* method) +{ + COROSIO_FAULT_REAL(SSL_CTX_new, void*(*)(void const*)); + if(should_fail(sys::SSL_CTX_new)) + return nullptr; + return real(method); +} + +extern "C" void* SSL_new(void* ctx) +{ + COROSIO_FAULT_REAL(SSL_new, void*(*)(void*)); + if(should_fail(sys::SSL_new)) + return nullptr; + return real(ctx); +} + +extern "C" int SSL_clear(void* ssl) +{ + COROSIO_FAULT_REAL(SSL_clear, int(*)(void*)); + if(should_fail(sys::SSL_clear)) + return 0; + return real(ssl); +} + +extern "C" int SSL_set_session(void* ssl, void* session) +{ + COROSIO_FAULT_REAL(SSL_set_session, int(*)(void*, void*)); + if(should_fail(sys::SSL_set_session)) + return 0; + return real(ssl, session); +} + +extern "C" int X509_STORE_add_cert(void* store, void* x) +{ + COROSIO_FAULT_REAL(X509_STORE_add_cert, int(*)(void*, void*)); + if(should_fail(sys::X509_STORE_add_cert)) + return 0; + return real(store, x); +} + +extern "C" void* X509_dup(void* x) +{ + COROSIO_FAULT_REAL(X509_dup, void*(*)(void*)); + if(should_fail(sys::X509_dup)) + return nullptr; + return real(x); +} + +extern "C" int BIO_read(void* bio, void* buf, int len) +{ + COROSIO_FAULT_REAL(BIO_read, int(*)(void*, void*, int)); + if(should_fail(sys::BIO_read)) + return -1; + return real(bio, buf, len); +} + +extern "C" int BIO_nwrite0(void* bio, char** buf) +{ + COROSIO_FAULT_REAL(BIO_nwrite0, int(*)(void*, char**)); + if(should_fail(sys::BIO_nwrite0)) + return -1; + return real(bio, buf); +} + + +extern "C" void* SSL_get0_param(void* ssl) +{ + COROSIO_FAULT_REAL(SSL_get0_param, void*(*)(void*)); + if(should_fail(sys::SSL_get0_param)) + return nullptr; + return real(ssl); +} + +extern "C" int X509_VERIFY_PARAM_set1_host(void* p, char const* name, + std::size_t namelen) +{ + COROSIO_FAULT_REAL(X509_VERIFY_PARAM_set1_host, + int(*)(void*, char const*, std::size_t)); + if(should_fail(sys::X509_VERIFY_PARAM_set1_host)) + return 0; + return real(p, name, namelen); +} +#endif // __linux__ + // pthread_create reports through its return value, not errno; the // armed error published to errno is handed back directly, which is // what turns std::thread's construction into a std::system_error. @@ -623,6 +734,9 @@ namespace { // the library name must not read as shared. int corosio_image_index() noexcept { + // The main library only: the char after the prefix must be '.', + // so the openssl/wolfssl satellite dylibs (which share the prefix + // but import no libc socket family) are not mistaken for it. static constexpr char prefix[] = "libboost_corosio"; for(std::uint32_t i = 1, n = ::_dyld_image_count(); i < n; ++i) { @@ -631,7 +745,8 @@ int corosio_image_index() noexcept continue; char const* slash = std::strrchr(path, '/'); char const* base = slash ? slash + 1 : path; - if(std::strncmp(base, prefix, sizeof(prefix) - 1) == 0) + if(std::strncmp(base, prefix, sizeof(prefix) - 1) == 0 && + base[sizeof(prefix) - 1] == '.') return static_cast(i); } return -1; @@ -715,6 +830,15 @@ namespace { COROSIO_FAULT_CENSUS(ftruncate), COROSIO_FAULT_CENSUS(fsync), COROSIO_FAULT_CENSUS(unlink), COROSIO_FAULT_CENSUS(sigaction), COROSIO_FAULT_CENSUS(pthread_create), +#if defined(__linux__) + COROSIO_FAULT_CENSUS(BIO_new_mem_buf), COROSIO_FAULT_CENSUS(BIO_new_bio_pair), + COROSIO_FAULT_CENSUS(SSL_CTX_new), COROSIO_FAULT_CENSUS(SSL_new), + COROSIO_FAULT_CENSUS(SSL_clear), COROSIO_FAULT_CENSUS(SSL_set_session), + COROSIO_FAULT_CENSUS(X509_STORE_add_cert), COROSIO_FAULT_CENSUS(X509_dup), + COROSIO_FAULT_CENSUS(BIO_read), COROSIO_FAULT_CENSUS(BIO_nwrite0), + COROSIO_FAULT_CENSUS(SSL_get0_param), + COROSIO_FAULT_CENSUS(X509_VERIFY_PARAM_set1_host), +#endif COROSIO_FAULT_CENSUS(getaddrinfo), COROSIO_FAULT_CENSUS(freeaddrinfo), COROSIO_FAULT_CENSUS(getnameinfo), COROSIO_FAULT_CENSUS(gethostname), diff --git a/test/unit/fault/tls_faults.cpp b/test/unit/fault/tls_faults.cpp new file mode 100644 index 000000000..549a890ee --- /dev/null +++ b/test/unit/fault/tls_faults.cpp @@ -0,0 +1,375 @@ +// +// 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 +// + +// OpenSSL refusal arms in the TLS engine. The engine builds its native +// context on the first handshake and caches the outcome per +// tls_context, so every test builds a fresh context; the cache-retains- +// failure contract gets its own second handshake. The BIO ordinals are +// fixed by the engine's construction order for a given configuration. + +#include + +#if defined(COROSIO_FAULT_HAS_OPENSSL) && !defined(_WIN32) + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include "fault.hpp" +#include "fault_test_utils.hpp" + +#include "test_utils.hpp" + +#include "test_suite.hpp" + +namespace boost::corosio::test::fault { + +namespace { + +// One handshake attempt against `server_ctx` over a mocket pair; +// reports the server's error. The client uses a healthy context so a +// failure is attributable to the armed server side. +// One client context for every test, warmed below: the engine builds +// its native context on first use per tls_context object, and a fresh +// client would otherwise consume the armed ordinal with its own build. +tls_context& +warm_client_ctx() +{ + static tls_context ctx = make_client_context(); + return ctx; +} + +std::error_code +server_handshake_ec(tls_context const& server_ctx) +{ + io_context ioc; + auto [m1, m2] = corosio::test::make_mocket_pair(ioc); + + auto client = openssl_stream(&m1, warm_client_ctx()); + auto server = openssl_stream(&m2, server_ctx); + + std::error_code server_ec; + auto client_hs = [&]() -> capy::task<> { + auto [ec] = co_await client.handshake(tls_role::client); + std::ignore = ec; + m1.close(); + }; + auto server_hs = [&]() -> capy::task<> { + auto [ec] = co_await server.handshake(tls_role::server); + server_ec = ec; + m2.close(); + }; + capy::run_async(ioc.get_executor())(client_hs()); + capy::run_async(ioc.get_executor())(server_hs()); + ioc.run(); + return server_ec; +} + +} // namespace + +struct tls_engine_faults +{ + bool skip() + { + if (!hook_is_live(sys::BIO_new_mem_buf)) + { + test_suite::log << "OpenSSL hooks not live; skipping\n"; + return true; + } + // Build the shared client's native context outside any armed + // window. + static bool warmed = [] { + auto healthy = make_server_context(); + std::ignore = server_handshake_ec(healthy); + return true; + }(); + std::ignore = warmed; + return false; + } + + // The credential decoder must fail the context, not crash, when + // any of its staging BIOs cannot be made: the ordinal selects the + // PKCS#12, entity-certificate, private-key, CA, or CRL site. + void testBioRefusalPerSite() + { + if (skip()) + return; + + struct site + { + char const* name; + unsigned nth; + tls_context (*make)(); + }; + site const sites[] = { + {"pkcs12", 1, + [] { + tls_context c; + require_ok(c.use_pkcs12( + std::string_view( + reinterpret_cast(server_p12), + sizeof(server_p12)), + p12_password)); + require_ok(c.set_verify_mode(tls_verify_mode::none)); + return c; + }}, + {"certificate", 1, [] { return make_server_context(); }}, + {"key", 2, [] { return make_server_context(); }}, + {"ca", 3, + [] { + auto c = make_server_context(); + require_ok(c.add_certificate_authority(ca_cert_pem)); + return c; + }}, + {"crl", 3, + [] { + auto c = make_server_context(); + require_ok(c.add_crl(revoked_crl_pem)); + c.set_revocation_policy(tls_revocation_policy::soft_fail); + return c; + }}, + }; + for (auto const& st : sites) + { + auto ctx = st.make(); + fault_scope f(sys::BIO_new_mem_buf, 0, st.nth); + auto ec = server_handshake_ec(ctx); + test_suite::log << "bio site " << st.name << ": fired=" + << f.fired() << " ec=" << ec.message() << "\n"; + BOOST_TEST(f.fired()); + BOOST_TEST(!!ec); + } + } + + // A chain certificate that cannot be duplicated must fail the + // context rather than install a partial chain. + void testChainDupRefusal() + { + if (skip()) + return; + tls_context c; + require_ok(c.use_pkcs12( + std::string_view(reinterpret_cast(server_chain_p12), + sizeof(server_chain_p12)), + p12_password)); + require_ok(c.set_verify_mode(tls_verify_mode::none)); + + fault_scope f(sys::X509_dup, 0); + auto ec = server_handshake_ec(c); + BOOST_TEST(f.fired()); + BOOST_TEST(!!ec); + } + + // A trust-store insertion that fails for a reason other than a + // duplicate must fail the context. + void testStoreAddRefusal() + { + if (skip()) + return; + auto c = make_server_context(); + require_ok(c.add_certificate_authority(ca_cert_pem)); + + fault_scope f(sys::X509_STORE_add_cert, 0); + auto ec = server_handshake_ec(c); + BOOST_TEST(f.fired()); + BOOST_TEST(!!ec); + } + + // SSL_CTX_new failing poisons the cached native context: the first + // handshake reports the failure and so must every later stream + // built from the same tls_context, without a rebuild. + void testContextAllocRefusalIsSticky() + { + if (skip()) + return; + auto c = make_server_context(); + { + fault_scope f(sys::SSL_CTX_new, 0); + auto ec = server_handshake_ec(c); + BOOST_TEST(f.fired()); + BOOST_TEST(!!ec); + } + // No arm: the cached failure alone must refuse the handshake. + auto ec2 = server_handshake_ec(c); + test_suite::log << "sticky ec2=" << ec2.message() << "\n"; + BOOST_TEST(ec2 == std::errc::not_enough_memory); + } + + // A second handshake on a used stream resets the engine first; a + // reset that cannot restore the session must refuse the handshake + // rather than hand out a dead session. + // A second handshake on a used stream resets the engine first; a + // reset that cannot clear the session, or cannot drop it, must + // refuse the handshake rather than hand out a dead session. + void testSessionResetRefusal() + { + if (skip()) + return; + for (sys which : {sys::SSL_clear, sys::SSL_set_session}) + { + io_context ioc; + auto [m1, m2] = corosio::test::make_mocket_pair(ioc); + auto server_ctx = make_server_context(); + auto client = openssl_stream(&m1, warm_client_ctx()); + auto server = openssl_stream(&m2, server_ctx); + + auto client_hs = [&]() -> capy::task<> { + auto [ec] = co_await client.handshake(tls_role::client); + BOOST_TEST(!ec); + }; + auto server_hs = [&]() -> capy::task<> { + auto [ec] = co_await server.handshake(tls_role::server); + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(client_hs()); + capy::run_async(ioc.get_executor())(server_hs()); + ioc.run(); + ioc.restart(); + + std::error_code second_ec; + auto server_hs2 = [&]() -> capy::task<> { + fault_scope f(which, 0); + auto [ec] = co_await server.handshake(tls_role::server); + second_ec = ec; + BOOST_TEST(f.fired()); + }; + capy::run_async(ioc.get_executor())(server_hs2()); + ioc.run(); + BOOST_TEST(second_ec == std::errc::invalid_argument); + } + } + + // Hostname verification setup: the parameter fetch and the host + // pinning can each refuse; a client that cannot pin the name must + // refuse the handshake rather than proceed unverified. + void testHostnameSetupRefusals() + { + if (skip()) + return; + for (sys which : {sys::SSL_get0_param, + sys::X509_VERIFY_PARAM_set1_host}) + { + io_context ioc; + auto [m1, m2] = corosio::test::make_mocket_pair(ioc); + auto server_ctx = make_server_context(); + auto client = openssl_stream(&m1, warm_client_ctx()); + auto server = openssl_stream(&m2, server_ctx); + client.set_hostname("localhost"); + + std::error_code cec; + auto client_hs = [&]() -> capy::task<> { + fault_scope f(which, 0); + auto [ec] = co_await client.handshake(tls_role::client); + cec = ec; + BOOST_TEST(f.fired()); + m1.close(); + }; + auto server_hs = [&]() -> capy::task<> { + auto [ec] = co_await server.handshake(tls_role::server); + std::ignore = ec; + m2.close(); + }; + capy::run_async(ioc.get_executor())(client_hs()); + capy::run_async(ioc.get_executor())(server_hs()); + ioc.run(); + BOOST_TEST(!!cec); + } + } + + // A staging-area refusal reads as "staging full" to the driver, + // which must retry rather than error or hang: the read still + // delivers the bytes once the next area request succeeds. + void testInputStagingRefusals() + { + if (skip()) + return; + for (sys which : {sys::BIO_nwrite0}) + { + io_context ioc; + auto [m1, m2] = corosio::test::make_mocket_pair(ioc); + auto server_ctx = make_server_context(); + auto client = openssl_stream(&m1, warm_client_ctx()); + auto server = openssl_stream(&m2, server_ctx); + + auto client_hs = [&]() -> capy::task<> { + auto [ec] = co_await client.handshake(tls_role::client); + BOOST_TEST(!ec); + }; + auto server_hs = [&]() -> capy::task<> { + auto [ec] = co_await server.handshake(tls_role::server); + BOOST_TEST(!ec); + }; + capy::run_async(ioc.get_executor())(client_hs()); + capy::run_async(ioc.get_executor())(server_hs()); + ioc.run(); + ioc.restart(); + + bool fired = false; + std::error_code rec; + bool done = false; + auto reader = [&]() -> capy::task<> { + fault_scope f(which, 0); + char buf[64]; + auto [ec, n] = co_await client.read_some( + capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + fired = f.fired(); + done = true; + }; + auto writer = [&]() -> capy::task<> { + auto [ec, n] = + co_await server.write_some(capy::const_buffer("hi", 2)); + std::ignore = ec; + std::ignore = n; + }; + capy::run_async(ioc.get_executor())(reader()); + capy::run_async(ioc.get_executor())(writer()); + std::ignore = ioc.run_for(std::chrono::seconds(2)); + if (!done) + { + m1.close(); + m2.close(); + ioc.restart(); + ioc.run(); + } + BOOST_TEST(done); + BOOST_TEST(fired); + BOOST_TEST(!rec); + } + } + + + void run() + { + testBioRefusalPerSite(); + testSessionResetRefusal(); + testHostnameSetupRefusals(); + testInputStagingRefusals(); + testChainDupRefusal(); + testStoreAddRefusal(); + testContextAllocRefusalIsSticky(); + } +}; + +TEST_SUITE(tls_engine_faults, "boost.corosio.fault.tls"); + +} // namespace boost::corosio::test::fault + +#endif // COROSIO_FAULT_HAS_OPENSSL From 1931732c2ed3a6ad367ab953911ac1c9042cd61e Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 4 Sep 2026 22:55:47 +0200 Subject: [PATCH 20/29] test(fault): reach thread creation on Windows toolchains Windows spawns its threads through _beginthreadex in the CRT, not pthread_create, so the POSIX thread entry never fires there. Patch the CRT's import thunk and expose the same fault, then cover the one place it surfaces cleanly under a bounded run: the wait reactor's first wait spawns its WSAPoll thread, and a spawn refusal must complete the wait with resource_unavailable_try_again rather than hang. --- test/unit/fault/fault.hpp | 5 ++- test/unit/fault/fault_win.cpp | 75 ++++++++++++++++++++++++++++++++-- test/unit/fault/win_faults.cpp | 54 ++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp index aaca36d3e..0e4cf21b9 100644 --- a/test/unit/fault/fault.hpp +++ b/test/unit/fault/fault.hpp @@ -33,7 +33,10 @@ namespace boost::corosio::test::fault { registrations included. `cpp_new` is not an OS symbol: it names the global allocation functions the fault target replaces, so arming it makes the nth `new` on the armed thread report `bad_alloc` (the - nothrow forms return null). + nothrow forms return null). `pthread_create` names thread creation + wherever it happens: the libc symbol on POSIX, and on Windows both + the winpthreads `pthread_create` and the CRT `_beginthreadex` that + MSVC's `std::thread` reaches through msvcp's import table. */ enum class sys { diff --git a/test/unit/fault/fault_win.cpp b/test/unit/fault/fault_win.cpp index d0aebc9ef..5c31da8ba 100644 --- a/test/unit/fault/fault_win.cpp +++ b/test/unit/fault/fault_win.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -197,7 +198,10 @@ using proc_t = void (*)(); 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)) + (cp, flags, in, inlen, out, outlen, dflt, used)) \ + X(pthread_create, int, static_cast(::GetLastError()), WINAPIV, \ + (void* t, void const* attr, void* (*fn)(void*), void* arg), \ + (t, attr, fn, arg)) // Entry points whose hook does more than fail: it substitutes a // pointer, rewrites a completion, or clamps a transfer. @@ -213,6 +217,7 @@ enum hook_id { COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_ID) COROSIO_FAULT_WIN_MANUAL(COROSIO_FAULT_WIN_ID1) + h_beginthreadex, hook_count }; @@ -518,6 +523,22 @@ BOOL WINAPI hooked_GetQueuedCompletionStatus(HANDLE port, LPDWORD bytes, return r; } +// MSVC's std::thread reaches _beginthreadex through msvcp's import +// table (or, with a static msvcp, through the executable's), and the +// CRT reports failure as a zero return with errno set. +std::uintptr_t WINAPIV hooked_beginthreadex(void* security, unsigned stack, + unsigned(__stdcall* fn)(void*), void* arg, unsigned flags, unsigned* id) +{ + if(should_fail(sys::pthread_create)) + { + errno = EAGAIN; + return 0; + } + return COROSIO_FAULT_WIN_CALL(beginthreadex, std::uintptr_t, WINAPIV, + (void*, unsigned, unsigned(__stdcall*)(void*), void*, unsigned, + unsigned*))(security, stack, fn, arg, flags, id); +} + struct hook_entry { char const* name; @@ -536,6 +557,8 @@ struct hook_entry hook_entry hooks[] = { COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_ROW) COROSIO_FAULT_WIN_MANUAL(COROSIO_FAULT_WIN_ROW1) + { "_beginthreadex", sys::pthread_create, + reinterpret_cast(&hooked_beginthreadex), 0 }, }; static_assert(sizeof(hooks) / sizeof(hooks[0]) == hook_count, @@ -691,6 +714,39 @@ void patch_module(HMODULE mod) noexcept }); } +// Patch exactly one import in a module the harness does not otherwise +// own: msvcp carries std::thread's call to _beginthreadex, and patching +// its whole table would let the harness intercept the runtime's +// unrelated calls, perturbing every other arm's call ordinals. The +// store is re-read in place, so the entry's `bound` is trustworthy +// without a separate verify pass. +void patch_module_one(HMODULE mod, hook_entry& h) noexcept +{ + for_each_import(mod, [&](char const* name, IMAGE_THUNK_DATA& thunk) + { + if(std::strcmp(name, h.name) != 0) + 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); + if(thunk.u1.Function == reinterpret_cast(h.hook)) + ++h.bound; + }); +} + // 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 @@ -816,6 +872,17 @@ int const installed = [] for(std::size_t i = 0; i < n; ++i) verify_module(mods[i], ok); + // std::thread's _beginthreadex call lives in msvcp's import table + // on dynamic-CRT MSVC toolchains; only that one slot is patched + // there. A static msvcp puts the call in the executable, which the + // walk above already covered, and MinGW reaches pthread_create the + // same way. + HMODULE msvcp = ::GetModuleHandleW(L"msvcp140.dll"); + if(!msvcp) + msvcp = ::GetModuleHandleW(L"msvcp140d.dll"); + if(msvcp) + patch_module_one(msvcp, hooks[h_beginthreadex]); + for(auto const& h : hooks) { if(h.bound != 0) @@ -857,10 +924,12 @@ bool hook_is_live(sys which) noexcept default: break; } + // Two rows can share an id (`pthread_create` also names + // `_beginthreadex`); any bound row makes the arm live. for(auto const& h : hooks) { - if(h.which == which) - return h.bound != 0; + if(h.which == which && h.bound != 0) + return true; } return false; } diff --git a/test/unit/fault/win_faults.cpp b/test/unit/fault/win_faults.cpp index 07a9ed042..b9055cd57 100644 --- a/test/unit/fault/win_faults.cpp +++ b/test/unit/fault/win_faults.cpp @@ -21,6 +21,9 @@ #include #include #include +#include +#include +#include #include #include @@ -29,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -892,6 +896,56 @@ struct win_signal_faults TEST_SUITE(win_signal_faults, "boost.corosio.fault.win.signals"); +// The first wait() on a socket spawns the wait reactor's poll thread. +// When that thread cannot be created the reactor is left untouched and +// the wait completes synchronously with resource_unavailable_try_again +// (queue_register returns ERROR_MAX_THRDS_REACHED, register_wait +// completes the op) rather than parking, so the run drains and returns. +// The bounded run is belt-and-suspenders: if that synchronous +// completion ever regressed the test would fail fast instead of +// stranding a runner. +struct win_wait_reactor_thread_faults +{ + void testWaitThreadSpawnRefusal() + { + if(!hook_is_live(sys::pthread_create)) + { + test_suite::log << "thread-creation hook not live; skipping\n"; + return; + } + + io_context ioc; + auto ex = ioc.get_executor(); + udp_socket u(ioc); + BOOST_TEST(!u.open(udp::v4())); + BOOST_TEST(!u.bind(endpoint(ipv4_address::loopback(), 0))); + + std::error_code wec = win_err(WSAEINTR); // sentinel, must change + bool done = false; + auto waiter = [&]() -> capy::task<> { + auto [ec] = co_await u.wait(wait_type::read); + wec = ec; + done = true; + }; + + fault_scope fault(sys::pthread_create, ERROR_MAX_THRDS_REACHED); + capy::run_async(ex)(waiter()); + std::ignore = ioc.run_for(std::chrono::seconds(5)); + + BOOST_TEST(fault.fired()); + BOOST_TEST(done); + BOOST_TEST(wec == std::errc::resource_unavailable_try_again); + } + + void run() + { + testWaitThreadSpawnRefusal(); + } +}; + +TEST_SUITE(win_wait_reactor_thread_faults, + "boost.corosio.fault.win.wait_thread"); + } // boost::corosio::test::fault #endif From 361f950b630e17ee56743518d58301ee9dc43dd2 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 4 Sep 2026 02:21:29 +0200 Subject: [PATCH 21/29] test(fault): add an on-demand submission-queue fill entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uring_sqe_full clamps at ring creation; uring_sq_fill exhausts a normally sized ring at a chosen moment instead. While armed, the next liburing call pushes the user-side tail to capacity — reversibly, with submit a no-op so nothing fake reaches the kernel — and the first call after the scope dies restores it. Covers ring construction tearing down and throwing when the wakeup poll cannot get an SQE, best-effort cancellation when the queue stays full through its flush-and-retry, the multishot arm failure path, the terminated-poll re-arms for the wakeup eventfd and the signal pipe (in a run and in the teardown drain), and the re-arm a terminated accept multishot posts. Composed arms taught the shadows to rewrite visible completions even while the fill holds the queue shut. --- test/unit/fault/fault.hpp | 8 +- test/unit/fault/fault_posix.cpp | 2 +- test/unit/fault/fault_uring.cpp | 72 ++++++++- test/unit/fault/uring_faults.cpp | 257 +++++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 3 deletions(-) diff --git a/test/unit/fault/fault.hpp b/test/unit/fault/fault.hpp index 0e4cf21b9..329c86e5b 100644 --- a/test/unit/fault/fault.hpp +++ b/test/unit/fault/fault.hpp @@ -26,6 +26,12 @@ 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. + `uring_sq_full` has an on-demand sibling, `uring_sq_fill`: while + armed, the next liburing call marks the ring's submission queue + full (reversibly, with submit a no-op so nothing fake reaches the + kernel), so `io_uring_get_sqe` fails at a chosen moment on a + normally sized ring; the fill is undone on the first liburing call + after the scope ends. `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 @@ -51,7 +57,7 @@ enum class sys 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, cpp_new, + io_uring_wait_cqe_timeout, uring_sqe_full, uring_sq_fill, cpp_new, // OpenSSL entry points the TLS engine drives; live only when the // process loads libssl/libcrypto. BIO_new_mem_buf, BIO_new_bio_pair, BIO_read, BIO_nwrite0, diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp index 78ba9f9eb..4a46c0edc 100644 --- a/test/unit/fault/fault_posix.cpp +++ b/test/unit/fault/fault_posix.cpp @@ -1200,7 +1200,7 @@ bool hook_is_live(sys which) noexcept return true; // 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) + if(which == sys::uring_sqe_full || which == sys::uring_sq_fill) which = sys::io_uring_submit; // Not a symbol either: it is a slice of the kevent shadow. if(which == sys::kevent_register) diff --git a/test/unit/fault/fault_uring.cpp b/test/unit/fault/fault_uring.cpp index d8b263602..e81c7c97c 100644 --- a/test/unit/fault/fault_uring.cpp +++ b/test/unit/fault/fault_uring.cpp @@ -61,6 +61,48 @@ slot* sqe_full_armed() noexcept return armed_arm(sys::uring_sqe_full); } +// On-demand SQ exhaustion: while an arm is live the ring's user-side +// tail is pushed to capacity so io_uring_get_sqe returns null, and +// io_uring_submit is a no-op so the fake tail never reaches the +// kernel. The first liburing call after the scope dies restores the +// saved tail; a ring torn down while filled is simply forgotten. +struct sq_fill_state +{ + io_uring* ring = nullptr; + unsigned saved_tail = 0; + bool filled = false; +}; + +thread_local sq_fill_state tls_sq_fill; + +void apply_sq_fill(io_uring* ring) noexcept +{ + auto& st = tls_sq_fill; + if(auto* s = armed_arm(sys::uring_sq_fill)) + { + if(!st.filled) + { + st.ring = ring; + st.saved_tail = ring->sq.sqe_tail; + ring->sq.sqe_tail += io_uring_sq_space_left(ring); + st.filled = true; + s->fired = true; + } + return; + } + if(st.filled && st.ring == ring) + { + ring->sq.sqe_tail = st.saved_tail; + st = sq_fill_state{}; + } +} + +bool sq_filled(io_uring* ring) noexcept +{ + return tls_sq_fill.filled && tls_sq_fill.ring == ring && + armed_arm(sys::uring_sq_fill) != nullptr; +} + // 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 @@ -112,7 +154,10 @@ extern "C" int io_uring_queue_init_params(unsigned entries, io_uring* ring, return rc; if(sqe_full_armed()) entries = 1; - return real(entries, ring, p); + int const r = real(entries, ring, p); + if(r == 0) + apply_sq_fill(ring); + return r; } extern "C" void io_uring_queue_exit(io_uring* ring) LIBURING_NOEXCEPT @@ -120,6 +165,8 @@ 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); + if(tls_sq_fill.filled && tls_sq_fill.ring == ring) + tls_sq_fill = sq_fill_state{}; real(ring); } @@ -135,6 +182,14 @@ extern "C" int io_uring_submit(io_uring* ring) LIBURING_NOEXCEPT return 0; } scan_pending_sqes(ring); + apply_sq_fill(ring); + if(sq_filled(ring)) + { + // The queue reads full, but completions already visible must + // still be rewritten or a composed arm misses its CQE. + rewrite_visible_cqes(ring); + return 0; + } 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 @@ -154,6 +209,12 @@ extern "C" int io_uring_submit_and_wait_timeout(io_uring* ring, io_uring_cqe** c if(uring_fail(sys::io_uring_submit_and_wait_timeout, rc)) return rc; scan_pending_sqes(ring); + apply_sq_fill(ring); + if(sq_filled(ring)) + { + rewrite_visible_cqes(ring); + return 0; + } rc = real(ring, cqe, wait_nr, ts, sigmask); rewrite_visible_cqes(ring); return rc; @@ -166,6 +227,14 @@ extern "C" int io_uring_submit_and_get_events(io_uring* ring) LIBURING_NOEXCEPT if(uring_fail(sys::io_uring_submit_and_get_events, rc)) return rc; scan_pending_sqes(ring); + apply_sq_fill(ring); + if(sq_filled(ring)) + { + // The queue reads full, but completions already visible must + // still be rewritten or a composed arm misses its CQE. + rewrite_visible_cqes(ring); + return 0; + } rc = real(ring); rewrite_visible_cqes(ring); return rc; @@ -178,6 +247,7 @@ extern "C" int io_uring_wait_cqe_timeout(io_uring* ring, io_uring_cqe** cqe, int rc; if(uring_fail(sys::io_uring_wait_cqe_timeout, rc)) return rc; + apply_sq_fill(ring); rc = real(ring, cqe, ts); rewrite_visible_cqes(ring); return rc; diff --git a/test/unit/fault/uring_faults.cpp b/test/unit/fault/uring_faults.cpp index e304e8baf..80f6e0a9a 100644 --- a/test/unit/fault/uring_faults.cpp +++ b/test/unit/fault/uring_faults.cpp @@ -32,8 +32,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -801,8 +803,263 @@ struct uring_faults BOOST_TEST(s2.is_open()); } + // Ring construction with no SQE for the wakeup poll must tear the + // ring back down and throw rather than run without a wake path. + void testRingInitSqExhaustion() + { + fault_scope f(sys::uring_sq_fill, 0); + BOOST_TEST_THROWS( + ([] { io_context tmp(io_uring); }()), std::system_error); + BOOST_TEST(f.fired()); + } + + // Cancellation is best-effort when the submission queue stays + // full after one flush: the op stays parked, and closing the + // socket still completes it. + void testCancelSqFullBestEffort() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + auto [s1, s2] = + test::make_socket_pair(ioc); + + std::stop_source ss; + char buf[8]; + std::error_code rec; + bool done = false; + auto reader = [&]() -> capy::task<> { + auto [ec, n] = + co_await s1.read_some(capy::mutable_buffer(buf, sizeof(buf))); + std::ignore = n; + rec = ec; + done = true; + }; + auto stopper = [&]() -> capy::task<> { + // The arm needs a run-loop flush to apply the fill before + // the stop callback submits its cancel inline; the delay + // hop provides one, and the scope legally spans the awaits + // on a single-threaded context. + fault_scope f(sys::uring_sq_fill, 0); + // A real suspension: a zero delay can complete inline + // without the run-loop flush that applies the fill. + std::ignore = + co_await corosio::delay(std::chrono::milliseconds(1)); + ss.request_stop(); + BOOST_TEST(f.fired()); + }; + capy::run_async(ex, ss.get_token())(reader()); + capy::run_async(ex)(stopper()); + // The skipped cancel leaves the read parked; the bounded run + // lets everything else settle, then the close completes it. + std::ignore = ioc.run_for(std::chrono::seconds(2)); + s1.close(); + ioc.restart(); + ioc.run(); + + BOOST_TEST(done); + BOOST_TEST(rec == capy::cond::canceled); + } + + // A multishot accept whose arming cannot get an SQE must complete + // the parked waiter with the error instead of parking it forever. + void testMultishotArmFailure() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + BOOST_TEST(!acc.listen()); + + tcp_socket peer(ioc); + std::error_code aec; + bool done = false; + bool fired = false; + auto arming = [&]() -> capy::task<> { + fault_scope f(sys::uring_sq_fill, 0); + std::ignore = + co_await corosio::delay(std::chrono::milliseconds(0)); + // The fill is applied; initiate the accept while the + // submission queue reads full so the multishot arming + // takes the failure path with the waiter parked. + auto [ec] = co_await acc.accept(peer); + aec = ec; + done = true; + fired = f.fired(); + }; + capy::run_async(ex)(arming()); + std::ignore = ioc.run_for(std::chrono::seconds(2)); + if (!done) + { + // The arming path did not consume the waiter; close so + // the run can finish and the assertions report it. + acc.close(); + ioc.restart(); + ioc.run(); + } + + BOOST_TEST(done); + BOOST_TEST(fired); + BOOST_TEST(!!aec); + BOOST_TEST(!peer.is_open()); + } + + // A multishot accept the kernel terminates (F_MORE cleared, no + // fatal error) is re-armed; a re-arm that cannot get an SQE must + // drain the parked waiters with the error rather than strand them. + void testMultishotRearmFailureWithWaiter() + { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + tcp_acceptor acc(ioc); + BOOST_TEST(!acc.open(tcp::v4())); + BOOST_TEST(!acc.bind(endpoint(ipv4_address::loopback(), 0))); + + // The multishot arms at listen(), so the scope watches before + // that: the arming SQE's CQE is rewritten to a spurious result + // with the termination bit cleared. + cqe_fault_scope q(-1, IORING_OP_ACCEPT, -EAGAIN, IORING_CQE_F_MORE); + BOOST_TEST(!acc.listen()); + + tcp_socket peer(ioc); + std::error_code aec; + bool done = false; + auto accepter = [&]() -> capy::task<> { + auto [ec] = co_await acc.accept(peer); + aec = ec; + done = true; + }; + auto trip = [&]() -> capy::task<> { + // Keep the queue full across the re-arm the rewritten CQE + // provokes; the raw connect generates that CQE. + fault_scope f(sys::uring_sq_fill, 0); + std::ignore = + co_await corosio::delay(std::chrono::milliseconds(1)); + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + BOOST_TEST_GE(fd, 0); + sockaddr_in sa{}; + sa.sin_family = AF_INET; + sa.sin_port = htons(acc.local_endpoint().port()); + sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + BOOST_TEST_EQ(::connect( + fd, reinterpret_cast(&sa), sizeof(sa)), 0); + // The rewritten CQE and the failed re-arm happen while the + // fill is held; the hop gives the run loop a chance to + // reach both. + std::ignore = + co_await corosio::delay(std::chrono::milliseconds(10)); + ::close(fd); + }; + capy::run_async(ex)(accepter()); + capy::run_async(ex)(trip()); + std::ignore = ioc.run_for(std::chrono::seconds(2)); + if (!done) + { + acc.close(); + ioc.restart(); + ioc.run(); + } + + BOOST_TEST(done); + BOOST_TEST(q.fired()); + BOOST_TEST(!!aec); + } + + + // The kernel may clear IORING_CQE_F_MORE on the wakeup eventfd's + // multishot poll; the scheduler must re-arm it or every later + // cross-thread wake is lost. + void testWakeupPollRearm() + { + cqe_fault_scope q(-1, IORING_OP_POLL_ADD, 1, IORING_CQE_F_MORE); + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + + std::atomic ran{0}; + for (int round = 0; round < 2; ++round) + { + std::thread poster([&] { + // The counter travels as a parameter: the poster's + // stack (and any closure on it) is gone before the + // run loop executes the frame. + capy::run_async(ex)([](std::atomic* n) -> capy::task<> { + n->fetch_add(1); + co_return; + }(&ran)); + }); + poster.join(); + ioc.restart(); + std::ignore = ioc.run(); + } + int const total = ran.load(); + BOOST_TEST(q.fired()); + BOOST_TEST_EQ(total, 2); + } + + // Same termination on the signal pipe's poll, during a run and + // during the shutdown drain. The process-global signal state must + // not leak into the parent, or the signal-pipe fault suite later + // in this binary finds the pipe already made and its arms never + // fire; the bodies fork. + void testSignalPipePollRearm() + { + in_child([] { + io_context ioc(io_uring); + auto ex = ioc.get_executor(); + // Armed after the ring exists, so the first pending + // POLL_ADD the scope matches is the signal pipe's. + cqe_fault_scope q(-1, IORING_OP_POLL_ADD, 1, IORING_CQE_F_MORE); + signal_set sigs(ioc); + if (sigs.add(SIGUSR1)) + return false; + + int got = 0; + auto task = [&]() -> capy::task<> { + auto [ec, sig] = co_await sigs.wait(); + if (!ec) + got = sig; + }; + capy::run_async(ex)(task()); + std::raise(SIGUSR1); + ioc.run(); + if (got != SIGUSR1 || !q.fired()) + return false; + + // The rearm must keep delivery alive. + int got2 = 0; + auto task2 = [&]() -> capy::task<> { + auto [ec, sig] = co_await sigs.wait(); + if (!ec) + got2 = sig; + }; + ioc.restart(); + capy::run_async(ex)(task2()); + std::raise(SIGUSR1); + ioc.run(); + return got2 == SIGUSR1; + }); + in_child([] { + // A terminated pipe poll arriving in the teardown drain. + io_context ioc(io_uring); + cqe_fault_scope q(-1, IORING_OP_POLL_ADD, 1, IORING_CQE_F_MORE); + signal_set sigs(ioc); + if (sigs.add(SIGUSR1)) + return false; + std::raise(SIGUSR1); + return true; + }); + } + void run() { + testRingInitSqExhaustion(); + testCancelSqFullBestEffort(); + testMultishotArmFailure(); + testMultishotRearmFailureWithWaiter(); + testWakeupPollRearm(); + testSignalPipePollRearm(); if(skip_under_valgrind()) return; testRingInitFails(); From e56e7dd21b76cd315cce0fb417ed72a710fbe485 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 4 Sep 2026 02:28:15 +0200 Subject: [PATCH 22/29] test(tls): report truncation when the peer dies before close_notify --- test/unit/openssl_stream.cpp | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/test/unit/openssl_stream.cpp b/test/unit/openssl_stream.cpp index 5dbe79014..23482fdfc 100644 --- a/test/unit/openssl_stream.cpp +++ b/test/unit/openssl_stream.cpp @@ -254,17 +254,21 @@ struct openssl_stream_test make_stream); } - // Transport wrapper whose writes fail on demand; reads pass - // through. Drives the driver's deferred-flush-error latch. + // Transport wrapper whose writes fail on demand and whose reads + // can turn into a clean zero-byte EOF. Drives the driver's + // deferred-flush-error latch and the shutdown truncation check. struct flush_fail_stream { corosio::test::mocket* m_; bool fail_writes_ = false; + bool eof_reads_ = false; std::error_code inject_ec_{}; template capy::io_task read_some(MutableBufferSequence buffers) { + if (eof_reads_) + co_return {std::error_code{}, 0}; co_return co_await m_->read_some(buffers); } @@ -433,6 +437,32 @@ struct openssl_stream_test } + void testShutdownOnDeadTransportReportsTruncation() + { + flush_fail_stream w{}; + runWrappedSession(w, [&w](io_context& ioc, auto& client, auto&, + auto& m1, auto& m2) { + // The peer vanishes without a close_notify: the transport + // reads clean EOF, and the driver must report the + // truncation on shutdown rather than a clean close. + w.eof_reads_ = true; + std::ignore = m1; + std::ignore = m2; + std::error_code sec; + bool done = false; + auto shutter = [&]() -> capy::task<> { + auto [ec] = co_await client.shutdown(); + sec = ec; + done = true; + }; + capy::run_async(ioc.get_executor())(shutter()); + ioc.run(); + BOOST_TEST(done); + BOOST_TEST(!!sec); + }); + } + + void run() { test::testIoBeforeHandshake(make_stream); @@ -493,6 +523,7 @@ struct openssl_stream_test testWriteFlushErrorIsLatched(); testCorruptRecordFailsReadAndShutdown(); testOversizedWriteRoundTrips(); + testShutdownOnDeadTransportReportsTruncation(); test::testReset(make_stream, cert_modes); test::testResetViaHandshake(make_stream, cert_modes); From 1d19e1e362610c32289f25a8a9ef7ae95bdecc1e Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 4 Sep 2026 02:39:12 +0200 Subject: [PATCH 23/29] chore(coverage): annotate verified-unreachable arms for the report Each exclusion states why the arm cannot execute: callers pre-check, eager validation upstream, vtable completeness, a second net behind a layer that reports first, or an interface no path on the platform drives. An exclusion is a reviewed assertion of unreachability, not a waiver; deleting the arm was preferred wherever an interface or a platform twin did not require it to stay. --- .../detail/io_uring/io_uring_acceptor_ops.hpp | 19 +++++++---- .../native/detail/io_uring/io_uring_types.hpp | 4 +++ .../native/detail/iocp/win_scheduler.hpp | 13 +++++--- .../native/detail/iocp/win_signals.hpp | 4 +-- .../detail/reactor/reactor_scheduler.hpp | 3 ++ include/boost/corosio/tcp_server.hpp | 3 ++ src/corosio/src/ipv6_address.cpp | 8 ++--- src/corosio/src/tls/detail/engine_driver.hpp | 6 ++-- src/openssl/src/detail/engine.cpp | 32 ++++++++++--------- 9 files changed, 57 insertions(+), 35 deletions(-) diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_acceptor_ops.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_acceptor_ops.hpp index f2d4ef93e..a1adc0ed3 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_acceptor_ops.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_acceptor_ops.hpp @@ -88,8 +88,8 @@ struct uring_multi_accept_op : io_uring_op static void do_retired_cqe( io_uring_op* /*base*/, int res, unsigned /*flags*/) noexcept { - if (res >= 0) - ::close(res); + if (res >= 0) // LCOV_EXCL_LINE adopt-over-armed race leak guard + ::close(res); // LCOV_EXCL_LINE adopt-over-armed race leak guard } static void do_cqe(io_uring_op* base, int res, unsigned flags, @@ -105,15 +105,15 @@ struct uring_multi_accept_op : io_uring_op // whether to surface the fd via a waiter or park it. } - /// Never invoked: the multishot op is owned by the acceptor and - /// never queued for handler dispatch. Provided so the vtable is - /// complete. + // LCOV_EXCL_START: never invoked; the multishot op is owned by + // the acceptor and never queued for handler dispatch. Provided so + // the vtable is complete. static void do_handler( void* /*owner*/, scheduler_op* /*base*/, std::uint32_t /*bytes*/, std::uint32_t /*error*/) noexcept { - // No-op. The acceptor's per-accept callback handles everything. } + // LCOV_EXCL_STOP }; /** Synthesized accept op — manufactured by the acceptor for parked fds. @@ -153,11 +153,13 @@ struct uring_accept_op : io_uring_op : io_uring_op(&do_handler, &do_cqe) {} + // LCOV_EXCL_START: never receives a CQE; present for vtable + // completeness. static void do_cqe(io_uring_op*, int, unsigned, ready_queue&) noexcept { - // Unreachable: this op never receives a CQE. } + // LCOV_EXCL_STOP static void do_handler( void* owner, scheduler_op* base, @@ -193,9 +195,12 @@ struct uring_accept_op : io_uring_op self->peer_service, self->accepted_fd, self->peer_storage, self->peer_len); + // LCOV_EXCL_START: no public accept overload reports the peer + // endpoint on this backend yet. if (self->peer_endpoint_out) *self->peer_endpoint_out = sockaddr_to_endpoint(self->peer_storage); + // LCOV_EXCL_STOP if (self->ec_out) *self->ec_out = {}; diff --git a/include/boost/corosio/native/detail/io_uring/io_uring_types.hpp b/include/boost/corosio/native/detail/io_uring/io_uring_types.hpp index b1b7e129b..cbbf44c78 100644 --- a/include/boost/corosio/native/detail/io_uring/io_uring_types.hpp +++ b/include/boost/corosio/native/detail/io_uring/io_uring_types.hpp @@ -2594,6 +2594,9 @@ class BOOST_COROSIO_DECL io_uring_local_datagram_socket final return remote_endpoint_; } + // LCOV_EXCL_START: the public bind routes through the + // service's bind_socket; nothing calls the implementation + // interface's bind on this backend. std::error_code bind(corosio::local_endpoint ep) noexcept override { sockaddr_storage addr{}; @@ -2609,6 +2612,7 @@ class BOOST_COROSIO_DECL io_uring_local_datagram_socket final local_endpoint_ = sockaddr_to_local_endpoint(local, local_len); return {}; } + // LCOV_EXCL_STOP private: std::coroutine_handle<> submit_send( diff --git a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp index bb5b1fddb..79fbf4c56 100644 --- a/include/boost/corosio/native/detail/iocp/win_scheduler.hpp +++ b/include/boost/corosio/native/detail/iocp/win_scheduler.hpp @@ -96,7 +96,10 @@ class BOOST_COROSIO_DECL win_scheduler final /// Return true when scheduler locking is disabled (fully-lockless tier). bool scheduler_locking_disabled() const noexcept override { + // LCOV_EXCL_START: consulted only by the POSIX pool-backed + // services; the IOCP services do not read it yet. return scheduler_locking_disabled_; + // LCOV_EXCL_STOP } /** Signal that an overlapped I/O operation is now pending. @@ -618,8 +621,8 @@ win_scheduler::do_one(unsigned long timeout_ms) return 1; } - default: - continue; + default: // LCOV_EXCL_LINE unreachable: closed key set + continue; // LCOV_EXCL_LINE unreachable: closed key set } } @@ -646,8 +649,10 @@ win_scheduler::do_one(unsigned long timeout_ms) } continue; - default: - continue; + // A key outside the closed set reaches here only if a + // third party posts to the port. + default: // LCOV_EXCL_LINE unreachable: closed key set + continue; // LCOV_EXCL_LINE unreachable: closed key set } } diff --git a/include/boost/corosio/native/detail/iocp/win_signals.hpp b/include/boost/corosio/native/detail/iocp/win_signals.hpp index 4a538eba2..581d56164 100644 --- a/include/boost/corosio/native/detail/iocp/win_signals.hpp +++ b/include/boost/corosio/native/detail/iocp/win_signals.hpp @@ -704,8 +704,8 @@ win_signals::start_wait(win_signal& impl, signal_op* op) inline void win_signals::deliver_signal(int signal_number) { - if (signal_number < 0 || signal_number >= max_signal_number) - return; + if (signal_number < 0 || signal_number >= max_signal_number) // LCOV_EXCL_LINE OS never delivers out-of-range + return; // LCOV_EXCL_LINE OS never delivers out-of-range signal_detail::signal_state* state = signal_detail::get_signal_state(); std::lock_guard lock(state->mutex); diff --git a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp index 2035cdecf..fbbc79712 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp @@ -286,8 +286,11 @@ class reactor_scheduler /// Sentinel op that triggers a reactor poll when dequeued. struct task_op final : scheduler_op { + // LCOV_EXCL_START: the sentinel is intercepted by pointer + // identity; its virtuals exist for vtable completeness. void operator()() override {} void destroy() override {} + // LCOV_EXCL_STOP }; task_op task_op_; diff --git a/include/boost/corosio/tcp_server.hpp b/include/boost/corosio/tcp_server.hpp index 05dd8d9fe..4e51e9a18 100644 --- a/include/boost/corosio/tcp_server.hpp +++ b/include/boost/corosio/tcp_server.hpp @@ -274,7 +274,10 @@ class BOOST_COROSIO_DECL tcp_server void return_void() noexcept {} void unhandled_exception() { + // LCOV_EXCL_START: terminating by contract is not a + // coverable outcome. std::terminate(); + // LCOV_EXCL_STOP } // Inject io_env for IoAwaitable diff --git a/src/corosio/src/ipv6_address.cpp b/src/corosio/src/ipv6_address.cpp index 498abb46b..7a46ab1fa 100644 --- a/src/corosio/src/ipv6_address.cpp +++ b/src/corosio/src/ipv6_address.cpp @@ -243,8 +243,8 @@ parse_h16( unsigned char& hi, unsigned char& lo) noexcept { - if (it == end) - return false; + if (it == end) // LCOV_EXCL_LINE callers pre-check end-of-input + return false; // LCOV_EXCL_LINE callers pre-check end-of-input int d = hexdig_value(*it); if (d < 0) @@ -377,8 +377,8 @@ parse_ipv6_impl(std::string_view s, ipv6_address& addr) noexcept // Verify it parsed correctly by re-parsing the exact substring auto [ckec, v4_check] = make_ipv4_address( std::string_view(it, static_cast(v4_it - it))); - if (ckec) - return ckec; + if (ckec) // LCOV_EXCL_LINE prefix of a parsed tail cannot fail + return ckec; // LCOV_EXCL_LINE prefix of a parsed tail cannot fail it = v4_it; auto const b4 = v4_check.to_bytes(); bytes[2 * (7 - n) + 0] = b4[0]; diff --git a/src/corosio/src/tls/detail/engine_driver.hpp b/src/corosio/src/tls/detail/engine_driver.hpp index b54141fe9..83fd247ad 100644 --- a/src/corosio/src/tls/detail/engine_driver.hpp +++ b/src/corosio/src/tls/detail/engine_driver.hpp @@ -227,8 +227,8 @@ class engine_driver // The loop guard just confirmed pending bytes exist, so a // drain failure here is unreachable in practice; fail loudly // rather than silently drop already-accepted ciphertext. - if (n == 0) - co_return make_error_code(std::errc::no_buffer_space); + if (n == 0) // LCOV_EXCL_LINE unreachable: pending bytes confirmed + co_return make_error_code(std::errc::no_buffer_space); // LCOV_EXCL_LINE unreachable: transport returned 0 with no error unreachable: pending bytes confirmed auto [ec, wn] = co_await capy::write( *s_, capy::const_buffer(out_buf_.data(), n)); if (ec) @@ -307,7 +307,7 @@ class engine_driver // The transport delivered nothing without an error, so it cannot // make progress: fail loudly rather than spin the engine's input // retry against a staging that will never fill. - co_return make_error_code(std::errc::no_buffer_space); + co_return make_error_code(std::errc::no_buffer_space); // LCOV_EXCL_LINE unreachable: staging cannot stay empty } // A prior read/write already reported its full transfer as success; diff --git a/src/openssl/src/detail/engine.cpp b/src/openssl/src/detail/engine.cpp index 0c11a1164..c0af17210 100644 --- a/src/openssl/src/detail/engine.cpp +++ b/src/openssl/src/detail/engine.cpp @@ -84,8 +84,8 @@ build_alpn_wire(std::vector const& protocols) std::string wire; for (auto const& p : protocols) { - if (p.empty() || p.size() > 255) - continue; + if (p.empty() || p.size() > 255) // LCOV_EXCL_LINE set_alpn validates eagerly + continue; // LCOV_EXCL_LINE set_alpn validates eagerly wire.push_back(static_cast(p.size())); wire.append(p); } @@ -158,8 +158,8 @@ static int password_callback(char* buf, int size, int rwflag, void* userdata) { auto* cd = static_cast(userdata); - if (!cd || !cd->password_callback) - return 0; + if (!cd || !cd->password_callback) // LCOV_EXCL_LINE installed only with a callback + return 0; // LCOV_EXCL_LINE installed only with a callback tls_password_purpose purpose = (rwflag == 0) ? tls_password_purpose::for_reading @@ -186,13 +186,13 @@ verify_callback_trampoline(int preverified, X509_STORE_CTX* store_ctx) { SSL* ssl = static_cast(X509_STORE_CTX_get_ex_data( store_ctx, SSL_get_ex_data_X509_STORE_CTX_idx())); - if (!ssl) - return preverified; + if (!ssl) // LCOV_EXCL_LINE ex-data set before verify runs + return preverified; // LCOV_EXCL_LINE ex-data set before verify runs auto* cd = static_cast( SSL_CTX_get_ex_data(SSL_get_SSL_CTX(ssl), sni_ctx_data_index)); - if (!cd) - return preverified; + if (!cd) // LCOV_EXCL_LINE set at context build + return preverified; // LCOV_EXCL_LINE set at context build bool ok = preverified != 0; @@ -246,8 +246,8 @@ alpn_select_cb( unsigned char const* in, unsigned int inlen, void* arg) { auto const* prefs = static_cast const*>(arg); - if (!prefs || prefs->empty()) - return SSL_TLSEXT_ERR_NOACK; // nothing configured (defensive) + if (!prefs || prefs->empty()) // LCOV_EXCL_LINE installed only with a non-empty list + return SSL_TLSEXT_ERR_NOACK; // LCOV_EXCL_LINE installed only with a non-empty list // Server preference order wins: for each server protocol, look for a // matching entry in the client's offered list. @@ -707,8 +707,8 @@ engine::init(tls_context const& ctx) void engine::reset() { - if (!ssl_) - return; + if (!ssl_) // LCOV_EXCL_LINE reset() runs only on a used stream + return; // LCOV_EXCL_LINE reset() runs only on a used stream // Preserves SSL* and BIO pair, releases session state if (SSL_clear(ssl_) != 1) @@ -911,9 +911,11 @@ engine::perform(engine_op op, void* data, std::size_t len) // apart, and the documented contract promises // stream_truncated for the latter, matching the read path // and the driver's `map_fill_error` policy. - ec = received_shutdown() - ? std::error_code{} - : make_error_code(capy::error::stream_truncated); + // The driver's map_fill_error reports the truncation + // before a BIO-pair engine can see SYSCALL. + ec = received_shutdown() // LCOV_EXCL_LINE driver maps truncation first + ? std::error_code{} // LCOV_EXCL_LINE driver maps truncation first + : make_error_code(capy::error::stream_truncated); // LCOV_EXCL_LINE driver maps truncation first } else { From d7d058db1f17487531af4b3e085ac8cebf64039d Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 4 Sep 2026 20:44:33 +0200 Subject: [PATCH 24/29] test(fault): reach the OpenSSL engine on Windows via IAT patching The engine's OpenSSL calls leave the corosio_openssl DLL through its import table; collect_modules already snapshots that satellite, so adding the OpenSSL entry points to the Windows hook table lets the patcher intercept them. Link the engine into the CMake Windows fault target (the coverage legs are CMake) and un-gate the TLS fault suite for Windows; a build without the engine loaded leaves the hooks unbound and the suite skips via hook_is_live. --- test/unit/fault/CMakeLists.txt | 9 ++++++--- test/unit/fault/fault_win.cpp | 31 +++++++++++++++++++++++++++++++ test/unit/fault/tls_faults.cpp | 2 +- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/test/unit/fault/CMakeLists.txt b/test/unit/fault/CMakeLists.txt index a1160da87..5c67f3d46 100644 --- a/test/unit/fault/CMakeLists.txt +++ b/test/unit/fault/CMakeLists.txt @@ -67,9 +67,12 @@ target_include_directories(boost_corosio_fault_tests PRIVATE . .. ../../../ ../../../src/corosio) # The OpenSSL shadows only have callers when the engine is linked; the -# TLS fault suite gates itself on this define. b2 never builds it: the -# coverage legs that publish the badges are all CMake. -if (NOT WIN32 AND OpenSSL_FOUND) +# TLS fault suite gates itself on this define. Linking the engine also +# loads its satellite so the interposers can reach it: POSIX and Darwin +# rebind the shadowed symbols, Windows patches the DLL's import table. +# b2 never builds it: the coverage legs that publish the badges are all +# CMake. +if (OpenSSL_FOUND) target_link_libraries(boost_corosio_fault_tests PRIVATE boost_corosio_openssl) target_compile_definitions(boost_corosio_fault_tests PRIVATE diff --git a/test/unit/fault/fault_win.cpp b/test/unit/fault/fault_win.cpp index 5c31da8ba..57cdef9dd 100644 --- a/test/unit/fault/fault_win.cpp +++ b/test/unit/fault/fault_win.cpp @@ -30,6 +30,7 @@ #include #include +#include #include #include #include @@ -210,12 +211,40 @@ using proc_t = void (*)(); X(WSAIoctl) X(GetQueuedCompletionStatus) X(GetProcAddress) \ X(FreeAddrInfoExW) X(signal) +// OpenSSL entry points the TLS engine drives, reached through the +// libboost_corosio_openssl satellite DLL's import table. Opaque +// pointer signatures: cdecl like the rest of OpenSSL, and no OpenSSL +// headers needed. Live only when that DLL is loaded, which the fault +// target arranges on the CMake legs. +#define COROSIO_FAULT_WIN_OPENSSL(X) \ + X(BIO_new_mem_buf, void*, nullptr, __cdecl, \ + (void const* buf, int len), (buf, len)) \ + X(BIO_new_bio_pair, int, 0, __cdecl, \ + (void** b1, std::size_t w1, void** b2, std::size_t w2), \ + (b1, w1, b2, w2)) \ + X(BIO_read, int, -1, __cdecl, (void* bio, void* buf, int len), \ + (bio, buf, len)) \ + X(BIO_nwrite0, int, -1, __cdecl, (void* bio, char** buf), (bio, buf)) \ + X(SSL_CTX_new, void*, nullptr, __cdecl, (void const* method), (method)) \ + X(SSL_new, void*, nullptr, __cdecl, (void* ctx), (ctx)) \ + X(SSL_clear, int, 0, __cdecl, (void* ssl), (ssl)) \ + X(SSL_set_session, int, 0, __cdecl, (void* ssl, void* session), \ + (ssl, session)) \ + X(SSL_get0_param, void*, nullptr, __cdecl, (void* ssl), (ssl)) \ + X(X509_STORE_add_cert, int, 0, __cdecl, (void* store, void* x), \ + (store, x)) \ + X(X509_dup, void*, nullptr, __cdecl, (void* x), (x)) \ + X(X509_VERIFY_PARAM_set1_host, int, 0, __cdecl, \ + (void* p, char const* name, std::size_t namelen), \ + (p, name, namelen)) + #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_OPENSSL(COROSIO_FAULT_WIN_ID) COROSIO_FAULT_WIN_MANUAL(COROSIO_FAULT_WIN_ID1) h_beginthreadex, hook_count @@ -238,6 +267,7 @@ proc_t reals[hook_count] = {}; } COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_HOOK) +COROSIO_FAULT_WIN_OPENSSL(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 @@ -556,6 +586,7 @@ struct hook_entry hook_entry hooks[] = { COROSIO_FAULT_WIN_SIMPLE(COROSIO_FAULT_WIN_ROW) + COROSIO_FAULT_WIN_OPENSSL(COROSIO_FAULT_WIN_ROW) COROSIO_FAULT_WIN_MANUAL(COROSIO_FAULT_WIN_ROW1) { "_beginthreadex", sys::pthread_create, reinterpret_cast(&hooked_beginthreadex), 0 }, diff --git a/test/unit/fault/tls_faults.cpp b/test/unit/fault/tls_faults.cpp index 549a890ee..1dbdb8d55 100644 --- a/test/unit/fault/tls_faults.cpp +++ b/test/unit/fault/tls_faults.cpp @@ -15,7 +15,7 @@ #include -#if defined(COROSIO_FAULT_HAS_OPENSSL) && !defined(_WIN32) +#if defined(COROSIO_FAULT_HAS_OPENSSL) #include #include From ed1bfdfe953d39f9fb37e8e5e8c35c6f65dc1ccb Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Fri, 4 Sep 2026 20:53:46 +0200 Subject: [PATCH 25/29] test(fault): reach the OpenSSL engine on macOS via satellite rebinding The OpenSSL shadows were confined to Linux because the main-library rebinder could not reach them: the engine's calls leave through the libboost_corosio_openssl satellite dylib, not the main image. Give the satellite its own rebinding pass, keyed off its dyld image index, that resolves each real entry through dlsym and rewrites the matching import slots. The pass is fail-soft: an entry that cannot be resolved or rebound is left not live, so the TLS fault suite skips it rather than any suite in the binary dying. The main pass now skips the OpenSSL names and the satellite pass owns their census_live. --- test/unit/fault/fault_posix.cpp | 111 +++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 9 deletions(-) diff --git a/test/unit/fault/fault_posix.cpp b/test/unit/fault/fault_posix.cpp index 4a46c0edc..d6baa7b14 100644 --- a/test/unit/fault/fault_posix.cpp +++ b/test/unit/fault/fault_posix.cpp @@ -207,7 +207,7 @@ COROSIO_FAULT_HOOK_NX(sigaction, int, -1, (int sig, struct sigaction const* a, s // RTLD_NEXT lookup has nothing to bind. Gate them to Linux, where the // coverage badge is measured; elsewhere hook_is_live reports them not // live and the TLS fault suite skips. -#if defined(__linux__) +#if defined(__linux__) || defined(__APPLE__) extern "C" void* BIO_new_mem_buf(void const* buf, int len) { COROSIO_FAULT_REAL(BIO_new_mem_buf, void*(*)(void const*, int)); @@ -308,7 +308,7 @@ extern "C" int X509_VERIFY_PARAM_set1_host(void* p, char const* name, return 0; return real(p, name, namelen); } -#endif // __linux__ +#endif // __linux__ || __APPLE__ // pthread_create reports through its return value, not errno; the // armed error published to errno is handed back directly, which is @@ -752,6 +752,25 @@ int corosio_image_index() noexcept return -1; } +// dyld's index for the loaded OpenSSL satellite dylib, or -1 when the +// engine is not linked into this process. +int corosio_openssl_image_index() noexcept +{ + static constexpr char prefix[] = "libboost_corosio_openssl"; + 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 && + base[sizeof(prefix) - 1] == '.') + return static_cast(i); + } + return -1; +} + } // namespace #endif @@ -830,7 +849,7 @@ namespace { COROSIO_FAULT_CENSUS(ftruncate), COROSIO_FAULT_CENSUS(fsync), COROSIO_FAULT_CENSUS(unlink), COROSIO_FAULT_CENSUS(sigaction), COROSIO_FAULT_CENSUS(pthread_create), -#if defined(__linux__) +#if defined(__linux__) || defined(__APPLE__) COROSIO_FAULT_CENSUS(BIO_new_mem_buf), COROSIO_FAULT_CENSUS(BIO_new_bio_pair), COROSIO_FAULT_CENSUS(SSL_CTX_new), COROSIO_FAULT_CENSUS(SSL_new), COROSIO_FAULT_CENSUS(SSL_clear), COROSIO_FAULT_CENSUS(SSL_set_session), @@ -838,7 +857,7 @@ namespace { COROSIO_FAULT_CENSUS(BIO_read), COROSIO_FAULT_CENSUS(BIO_nwrite0), COROSIO_FAULT_CENSUS(SSL_get0_param), COROSIO_FAULT_CENSUS(X509_VERIFY_PARAM_set1_host), -#endif +#endif // __linux__ || __APPLE__ COROSIO_FAULT_CENSUS(getaddrinfo), COROSIO_FAULT_CENSUS(freeaddrinfo), COROSIO_FAULT_CENSUS(getnameinfo), COROSIO_FAULT_CENSUS(gethostname), @@ -896,6 +915,16 @@ bool census_live[census_count]; return std::strncmp(name, "__", 2) == 0 || std::strchr(name, '$'); } +// The OpenSSL entry points, distinguished by their library prefix (no +// libc symbol begins with any of these). They live in the +// libboost_corosio_openssl satellite, not the main library. +[[maybe_unused]] bool is_openssl_entry(char const* name) noexcept +{ + return std::strncmp(name, "BIO_", 4) == 0 || + std::strncmp(name, "SSL_", 4) == 0 || + std::strncmp(name, "X509_", 5) == 0; +} + #if defined(__APPLE__) // One census symbol whose import slot in the dylib is to be rewritten. @@ -1080,8 +1109,9 @@ void interpose_corosio_dylib() noexcept { // 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)) + // point, which a value match cannot tell apart. OpenSSL names + // belong to the satellite dylib, handled by its own pass. + if(is_alias_entry(e.name) || is_openssl_entry(e.name)) continue; void* real = ::dlsym(RTLD_NEXT, e.name); if(!real) @@ -1139,6 +1169,65 @@ void interpose_corosio_dylib() noexcept die("fault harness: the corosio dylib's imports were not rebound"); } } + +// Interpose the OpenSSL entry points in the satellite dylib. Fail-soft +// by design: an entry that cannot be resolved or rebound is simply left +// not live (its census_live stays false, so the TLS fault suite skips +// it), never fatal. This keeps a build where the engine is absent, or a +// linker layout the rebinder cannot reach, from breaking every other +// fault suite in the binary. `census_live` for the OpenSSL entries is +// set here. +void interpose_openssl_dylib() noexcept +{ + int const image = corosio_openssl_image_index(); + if(image < 0) + return; + auto const* hdr = reinterpret_cast( + ::_dyld_get_image_header(static_cast(image))); + if(!hdr || hdr->magic != MH_MAGIC_64) + return; + auto const slide = + ::_dyld_get_image_vmaddr_slide(static_cast(image)); + + rebind_target targets[sizeof(census) / sizeof(census[0])] = {}; + std::size_t census_of[sizeof(census) / sizeof(census[0])] = {}; + std::size_t n = 0; + for(std::size_t i = 0; i < census_count; ++i) + { + auto const& e = census[i]; + if(!is_openssl_entry(e.name)) + continue; + // Resolve the real function the same way the shadow will; if it + // cannot be found here it would only die when first called, so + // skip it and leave the entry not live. + void* real = ::dlsym(RTLD_NEXT, e.name); + if(!real) + continue; + targets[n].name = e.name; + targets[n].hook = e.hook; + targets[n].real = real; + census_of[n] = i; + ++n; + } + if(n == 0) + return; + + 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) + { + // Live only if every slot for the symbol moved to the hook. + bool const bound = targets[k].rebound != 0 && targets[k].unbound == 0; + census_live[census_of[k]] = bound; + if(!bound) + std::fprintf(stderr, + "fault harness: OpenSSL %s not interposed in the satellite " + "dylib (rebound %u, still real %u)\n", + targets[k].name, targets[k].rebound, targets[k].unbound); + } +} #endif // A shared build only reaches the shadows through the dynamic loader, @@ -1157,10 +1246,14 @@ int const readback = [] 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. + // It dies unless every non-alias, non-OpenSSL 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); + census_live[i] = !is_alias_entry(census[i].name) && + !is_openssl_entry(census[i].name); + // The OpenSSL satellite is optional and its rebinding is best-effort; + // this sets census_live for the entries it interposes. + interpose_openssl_dylib(); #else bool ok = true; for(std::size_t i = 0; i < census_count; ++i) From 749738757363ae9e15c9de6f2f9813824467ba20 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Sat, 5 Sep 2026 01:26:10 +0200 Subject: [PATCH 26/29] refactor(reactor): pass the always-live reactor context by reference do_one, run_task, and the work_cleanup / task_cleanup guards all receive the running thread's context frame, which is never null: do_one is only ever entered with a live frame from run/run_one/poll. Typing it as a reference makes that invariant hold in the type system rather than by convention, so no caller can pass null and no guard needs a null check. The nullable lookup, reactor_find_context, keeps its pointer. --- .../native/detail/epoll/epoll_scheduler.hpp | 4 +- .../native/detail/kqueue/kqueue_scheduler.hpp | 4 +- .../detail/reactor/reactor_scheduler.hpp | 42 +++++++++---------- .../native/detail/select/select_scheduler.hpp | 4 +- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp b/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp index 77ee43e84..b3c33de0f 100644 --- a/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp +++ b/include/boost/corosio/native/detail/epoll/epoll_scheduler.hpp @@ -135,7 +135,7 @@ class BOOST_COROSIO_DECL epoll_scheduler final : public reactor_scheduler private: void - run_task(lock_type& lock, context_type* ctx, + run_task(lock_type& lock, context_type& ctx, long timeout_us) override; void interrupt_reactor() const override; void update_timerfd() const; @@ -347,7 +347,7 @@ epoll_scheduler::update_timerfd() const inline void epoll_scheduler::run_task( - lock_type& lock, context_type* ctx, long timeout_us) + lock_type& lock, context_type& ctx, long timeout_us) { int timeout_ms; if (task_interrupted_) diff --git a/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp index 87c4e7eba..153f1d551 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_scheduler.hpp @@ -156,7 +156,7 @@ class BOOST_COROSIO_DECL kqueue_scheduler final : public reactor_scheduler private: void - run_task(lock_type& lock, context_type* ctx, + run_task(lock_type& lock, context_type& ctx, long timeout_us) override; void interrupt_reactor() const override; long calculate_timeout(long requested_timeout_us) const; @@ -335,7 +335,7 @@ kqueue_scheduler::calculate_timeout(long requested_timeout_us) const inline void kqueue_scheduler::run_task( - lock_type& lock, context_type* ctx, long timeout_us) + lock_type& lock, context_type& ctx, long timeout_us) { long effective_timeout_us = task_interrupted_ ? 0 : calculate_timeout(timeout_us); diff --git a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp index fbbc79712..0413bb658 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_scheduler.hpp @@ -257,7 +257,7 @@ class reactor_scheduler { reactor_scheduler const* sched; lock_type* lock; - context_type* ctx; + context_type& ctx; ~task_cleanup(); }; @@ -303,7 +303,7 @@ class reactor_scheduler errors it retries rather than reports. */ virtual void - run_task(lock_type& lock, context_type* ctx, + run_task(lock_type& lock, context_type& ctx, long timeout_us) = 0; /// Wake a blocked reactor (e.g. write to eventfd or pipe). @@ -314,12 +314,12 @@ class reactor_scheduler { reactor_scheduler* sched; lock_type* lock; - context_type* ctx; + context_type& ctx; ~work_cleanup(); }; std::size_t do_one( - lock_type& lock, long timeout_us, context_type* ctx); + lock_type& lock, long timeout_us, context_type& ctx); void signal_all(lock_type& lock) const; bool maybe_unlock_and_signal_one(lock_type& lock) const; @@ -572,7 +572,7 @@ reactor_scheduler::run() std::size_t n = 0; for (;;) { - if (!do_one(lock, -1, &ctx.frame_)) + if (!do_one(lock, -1, ctx.frame_)) break; if (n != (std::numeric_limits::max)()) ++n; @@ -593,7 +593,7 @@ reactor_scheduler::run_one() reactor_thread_context_guard ctx(this); lock_type lock(mutex_); - return do_one(lock, -1, &ctx.frame_); + return do_one(lock, -1, ctx.frame_); } inline std::size_t @@ -607,7 +607,7 @@ reactor_scheduler::wait_one(long usec) reactor_thread_context_guard ctx(this); lock_type lock(mutex_); - return do_one(lock, usec, &ctx.frame_); + return do_one(lock, usec, ctx.frame_); } inline std::size_t @@ -625,7 +625,7 @@ reactor_scheduler::poll() std::size_t n = 0; for (;;) { - if (!do_one(lock, 0, &ctx.frame_)) + if (!do_one(lock, 0, ctx.frame_)) break; if (n != (std::numeric_limits::max)()) ++n; @@ -646,7 +646,7 @@ reactor_scheduler::poll_one() reactor_thread_context_guard ctx(this); lock_type lock(mutex_); - return do_one(lock, 0, &ctx.frame_); + return do_one(lock, 0, ctx.frame_); } inline void @@ -800,41 +800,41 @@ reactor_scheduler::wake_one_thread_and_unlock( inline reactor_scheduler::work_cleanup::~work_cleanup() { - std::int64_t produced = ctx->private_outstanding_work; + std::int64_t produced = ctx.private_outstanding_work; if (produced > 1) sched->outstanding_work_.fetch_add( produced - 1, std::memory_order_relaxed); else if (produced < 1) sched->work_finished(); - ctx->private_outstanding_work = 0; + ctx.private_outstanding_work = 0; - if (!ctx->private_queue.empty()) + if (!ctx.private_queue.empty()) { lock->lock(); - sched->completed_ops_.splice(ctx->private_queue); + sched->completed_ops_.splice(ctx.private_queue); } } inline reactor_scheduler::task_cleanup::~task_cleanup() { - if (ctx->private_outstanding_work > 0) + if (ctx.private_outstanding_work > 0) { sched->outstanding_work_.fetch_add( - ctx->private_outstanding_work, std::memory_order_relaxed); - ctx->private_outstanding_work = 0; + ctx.private_outstanding_work, std::memory_order_relaxed); + ctx.private_outstanding_work = 0; } - if (!ctx->private_queue.empty()) + if (!ctx.private_queue.empty()) { if (!lock->owns_lock()) lock->lock(); - sched->completed_ops_.splice(ctx->private_queue); + sched->completed_ops_.splice(ctx.private_queue); } } inline std::size_t reactor_scheduler::do_one( - lock_type& lock, long timeout_us, context_type* ctx) + lock_type& lock, long timeout_us, context_type& ctx) { for (;;) { @@ -892,12 +892,12 @@ reactor_scheduler::do_one( { // Wake a peer for the remaining work; unassisted if none // was parked to take it. - ctx->unassisted = !unlock_and_signal_one(lock); + ctx.unassisted = !unlock_and_signal_one(lock); } else { // No peer to wake (one_thread_, or nothing more queued). - ctx->unassisted = more; + ctx.unassisted = more; lock.unlock(); } diff --git a/include/boost/corosio/native/detail/select/select_scheduler.hpp b/include/boost/corosio/native/detail/select/select_scheduler.hpp index a885a96e7..6ff32ee6a 100644 --- a/include/boost/corosio/native/detail/select/select_scheduler.hpp +++ b/include/boost/corosio/native/detail/select/select_scheduler.hpp @@ -140,7 +140,7 @@ class BOOST_COROSIO_DECL select_scheduler final : public reactor_scheduler private: void - run_task(lock_type& lock, context_type* ctx, + run_task(lock_type& lock, context_type& ctx, long timeout_us) override; void interrupt_reactor() const override; long calculate_timeout(long requested_timeout_us) const; @@ -329,7 +329,7 @@ select_scheduler::calculate_timeout(long requested_timeout_us) const inline void select_scheduler::run_task( - lock_type& lock, context_type* ctx, long timeout_us) + lock_type& lock, context_type& ctx, long timeout_us) { long effective_timeout_us = task_interrupted_ ? 0 : calculate_timeout(timeout_us); From 19b9d12e85dfa4a8a002a09ec0ef20d8faa5bbc7 Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Sat, 5 Sep 2026 01:38:47 +0200 Subject: [PATCH 27/29] fix(test): keep the acceptor re-bind off the just-released port testReleaseDropsPreacceptedConnections re-binds the same acceptor to an ephemeral port and depends on it differing from the released one so the accept can tell the new listener from the old. SO_REUSEADDR lets the kernel hand the just-freed port straight back, so retry the ephemeral bind until it lands on a different port instead of asserting the first result is different. --- test/unit/tcp_acceptor.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/test/unit/tcp_acceptor.cpp b/test/unit/tcp_acceptor.cpp index a5fa75d53..e76f50d45 100644 --- a/test/unit/tcp_acceptor.cpp +++ b/test/unit/tcp_acceptor.cpp @@ -1680,13 +1680,22 @@ struct tcp_acceptor_test close_native_socket(released); close_native_socket(stale); - BOOST_TEST(!acc.open()); - acc.set_option(socket_option::reuse_address(true)); - ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); - BOOST_TEST(!ec); - ec = acc.listen(); - BOOST_TEST(!ec); - auto port_b = acc.local_endpoint().port(); + // SO_REUSEADDR lets the kernel hand back the just-freed port_a; + // retry the ephemeral re-bind until it differs so the accept + // below can tell the new listener apart from the released one. + auto port_b = port_a; + for (int attempt = 0; attempt < 16 && port_b == port_a; ++attempt) + { + BOOST_TEST(!acc.open()); + acc.set_option(socket_option::reuse_address(true)); + ec = acc.bind(endpoint(ipv4_address::loopback(), 0)); + BOOST_TEST(!ec); + ec = acc.listen(); + BOOST_TEST(!ec); + port_b = acc.local_endpoint().port(); + if (port_b == port_a) + close_native_socket(acc.release()); + } BOOST_TEST(port_b != port_a); auto client = make_native_socket(AF_INET, SOCK_STREAM); From 4ed408896f569bccc8ee3022ae0255afddfdc58a Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Sat, 5 Sep 2026 02:49:19 +0200 Subject: [PATCH 28/29] test(coverage): exercise leave_group_v6 static traits directly The IPv6 leave-group option's level, name, size, and byte layout were only reachable through a live multicast join, which the coverage host cannot route, so those pure accessors went unmeasured. Drive them directly against the join-group option's matching traits. --- test/unit/socket_option.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/unit/socket_option.cpp b/test/unit/socket_option.cpp index 96309d17f..565d0d9de 100644 --- a/test/unit/socket_option.cpp +++ b/test/unit/socket_option.cpp @@ -137,6 +137,20 @@ struct socket_option_test sock.close(); } + // leave_group_v6's static traits and byte layout, exercised directly + // so they are covered on hosts with no multicast route to join. + void testLeaveGroupV6Traits() + { + socket_option::leave_group_v6 leave(ipv6_address("ff02::1"), 0); + socket_option::join_group_v6 join(ipv6_address("ff02::1")); + BOOST_TEST_EQ(socket_option::leave_group_v6::level(), + socket_option::join_group_v6::level()); + BOOST_TEST(socket_option::leave_group_v6::name() != + socket_option::join_group_v6::name()); + BOOST_TEST_EQ(leave.size(), join.size()); + BOOST_TEST(leave.data() != nullptr); + } + void testV6Only() { io_context ioc(Backend); @@ -247,6 +261,7 @@ struct socket_option_test testTcpLocalEndpoint(); testUdpOptions(); testMulticastOptions(); + testLeaveGroupV6Traits(); testV6Only(); testClosedSocketThrows(); testInvalidOptionReportsError(); From 94e59047295c89c7c4572d63c410be2a0116c2ac Mon Sep 17 00:00:00 2001 From: Steve Gerbino Date: Sat, 5 Sep 2026 02:56:17 +0200 Subject: [PATCH 29/29] test(coverage): drive the file and accept-socket resume-time cancel arms The native stream-file and random-access-file awaitables carry the same resume-time stop check as the sockets, and the acceptors' accept()-> socket overload has its own; none were driven with a stopped token. Add a file resume-cancel case and exercise the socket-returning accept alongside the existing accept-into-peer arm. --- test/unit/native/native_resume_cancel.cpp | 62 ++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/test/unit/native/native_resume_cancel.cpp b/test/unit/native/native_resume_cancel.cpp index 878b37be8..c2c719e4f 100644 --- a/test/unit/native/native_resume_cancel.cpp +++ b/test/unit/native/native_resume_cancel.cpp @@ -17,6 +17,8 @@ #include #include +#include +#include #include #include #include @@ -29,6 +31,8 @@ #include #include +#include +#include #include #include @@ -140,13 +144,16 @@ struct native_resume_cancel_test auto [aec] = co_await acc.accept(peer); if (aec == capy::cond::canceled) ++canceled; + auto [sec, sock] = co_await acc.accept(); + if (sec == capy::cond::canceled) + ++canceled; auto [wec] = co_await acc.wait(wait_type::read); if (wec == capy::cond::canceled) ++canceled; }; capy::run_async(ex, ss.get_token())(driver()); ioc.run(); - BOOST_TEST_EQ(canceled, 2); + BOOST_TEST_EQ(canceled, 3); BOOST_TEST(!peer.is_open()); } @@ -206,6 +213,53 @@ struct native_resume_cancel_test BOOST_TEST_EQ(canceled, 6); } + // The file awaitables carry the same resume-time stop check as the + // sockets; no other suite drives a file op with a stopped token. + void testFileResumeCancel() + { + io_context ioc(Backend); + auto ex = ioc.get_executor(); + auto rp = std::filesystem::temp_directory_path() / "corosio_rc_r.tmp"; + auto wp = std::filesystem::temp_directory_path() / "corosio_rc_w.tmp"; + { std::ofstream(rp) << "hello"; } + { std::ofstream w(wp); } + + native_stream_file sfr(ioc), sfw(ioc); + native_random_access_file rfr(ioc), rfw(ioc); + BOOST_TEST(!sfr.open(rp.string(), file_base::read_only)); + BOOST_TEST(!sfw.open(wp.string(), file_base::write_only)); + BOOST_TEST(!rfr.open(rp.string(), file_base::read_only)); + BOOST_TEST(!rfw.open(wp.string(), file_base::write_only)); + + std::stop_source ss; + ss.request_stop(); + char buf[8]; + int canceled = 0; + auto driver = [&]() -> capy::task<> { + auto [a, an] = + co_await sfr.read_some(capy::mutable_buffer(buf, sizeof(buf))); + if (a == capy::cond::canceled && an == 0) + ++canceled; + auto [b, bn] = co_await sfw.write_some(capy::const_buffer("x", 1)); + if (b == capy::cond::canceled && bn == 0) + ++canceled; + auto [c, cn] = co_await rfr.read_some_at( + 0, capy::mutable_buffer(buf, sizeof(buf))); + if (c == capy::cond::canceled && cn == 0) + ++canceled; + auto [d, dn] = + co_await rfw.write_some_at(0, capy::const_buffer("x", 1)); + if (d == capy::cond::canceled && dn == 0) + ++canceled; + }; + capy::run_async(ex, ss.get_token())(driver()); + ioc.run(); + BOOST_TEST_EQ(canceled, 4); + std::error_code rm; + std::filesystem::remove(rp, rm); + std::filesystem::remove(wp, rm); + } + #if BOOST_COROSIO_POSIX void testLocalStreamPreStopped() { @@ -264,13 +318,16 @@ struct native_resume_cancel_test auto [aec] = co_await acc.accept(peer); if (aec == capy::cond::canceled) ++canceled; + auto [sec, sock] = co_await acc.accept(); + if (sec == capy::cond::canceled) + ++canceled; auto [wec] = co_await acc.wait(wait_type::read); if (wec == capy::cond::canceled) ++canceled; }; capy::run_async(ex, ss.get_token())(driver()); ioc.run(); - BOOST_TEST_EQ(canceled, 2); + BOOST_TEST_EQ(canceled, 3); BOOST_TEST(!peer.is_open()); } @@ -339,6 +396,7 @@ struct native_resume_cancel_test testTcpStopAfterDataBuffered(); testTcpAcceptorPreStopped(); testUdpPreStopped(); + testFileResumeCancel(); #if BOOST_COROSIO_POSIX testLocalStreamPreStopped(); testLocalStreamAcceptorPreStopped();