From 320dee455ff724157693d2a849827564fccbd436 Mon Sep 17 00:00:00 2001 From: Michael Vandeberg Date: Fri, 17 Jul 2026 10:54:10 -0600 Subject: [PATCH] fix!: normalize remote/connection error conditions across backends (#304) Make cond::canceled mean only local, deliberate cancellation on every backend, and surface remote/connection failures as the portable std::errc condition consistently across epoll/io_uring/kqueue/select/IOCP. Previously the IOCP and kqueue backends diverged from POSIX: - IOCP mapped ERROR_NETNAME_DELETED unconditionally to canceled, so a remote RST looked identical to a local cancel. It is now disambiguated per operation kind in the new iocp_make_err (connection_reset for stream read/write, connection_aborted for accept); make_err no longer folds it into canceled. The cancelled flag, set before the raw error is consulted, keeps genuine local closes (close()/cancel()/stop-token) as canceled. - IOCP surfaced NTSTATUS-derived Win32 socket codes (e.g. ERROR_CONNECTION_REFUSED 1225) that MSVC's system_category does not map to std::errc. iocp_make_err now normalizes the observed ones to their Winsock equivalents (connection_refused, network_unreachable, timed_out) so they compare equal to the portable conditions like POSIX does. - IOCP close_socket() flagged only the aux-reactor wait op cancelled, relying on make_err's old NETNAME_DELETED->canceled fold for the overlapped read/write/connect/accept ops. With that fold gone, close_socket() now request_cancel()s every op before tearing down the handle (as cancel() does), so a local close stays canceled regardless of the CancelIoEx-vs- closesocket race. Applied across tcp, udp, and local-stream services. - kqueue stripped a user-set SO_LINGER before close (a macOS workaround), turning an abortive close into a graceful FIN so the peer saw eof instead of connection_reset. RST is in fact delivered reliably (EV_EOF carries ECONNRESET in fflags), so the strip is removed and SO_LINGER is passed through, matching the other backends. Add test/unit/error_conditions.cpp: a cross-backend audit (run under every backend) asserting the portable contract for the remote/connection error family -- FIN->eof, remote reset is never canceled, write-to-dead-peer is connection_reset|broken_pipe, refused connect is never canceled. --- .../ROOT/pages/5.testing/5b.socket-pair.adoc | 8 +- .../win_local_stream_acceptor_service.hpp | 6 +- .../detail/iocp/win_local_stream_service.hpp | 6 + .../native/detail/iocp/win_overlapped_op.hpp | 69 +++++- .../detail/iocp/win_tcp_acceptor_service.hpp | 25 +- .../native/detail/iocp/win_udp_service.hpp | 8 + .../native/detail/kqueue/kqueue_traits.hpp | 49 +--- .../boost/corosio/native/detail/make_err.hpp | 17 +- .../detail/reactor/reactor_socket_service.hpp | 3 +- test/unit/error_conditions.cpp | 219 ++++++++++++++++++ 10 files changed, 350 insertions(+), 60 deletions(-) create mode 100644 test/unit/error_conditions.cpp diff --git a/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc b/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc index 2bb93e852..9a3c8ae40 100644 --- a/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc +++ b/doc/modules/ROOT/pages/5.testing/5b.socket-pair.adoc @@ -131,10 +131,10 @@ xref:5.testing/5c.patterns.adoc[Testing Patterns]. * Slower than `mocket` for byte-level assertions. * Timing is not deterministic; use stop_tokens or timers for cancellation paths. -* On Linux, `SO_LINGER(true, 0)` causes `close()` to send `RST`, which - may surface as `cond::connection_reset` on the other end depending on - the order of operations. If a test asserts on a clean shutdown - sequence, set `Linger=false`. +* `SO_LINGER(true, 0)` (the default `Linger=true`) causes `close()` to send + `RST`, which surfaces on the other end as `std::errc::connection_reset` on + every backend. Exact timing is not deterministic, so assert on the error + condition rather than a clean shutdown sequence, or set `Linger=false`. == Next Steps diff --git a/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp index ecb6383b1..e2394b478 100644 --- a/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_local_stream_acceptor_service.hpp @@ -152,7 +152,7 @@ local_stream_accept_op::do_complete( if (op->cancelled.load(std::memory_order_acquire)) *op->ec_out = capy::error::canceled; else if (op->dwError != 0) - *op->ec_out = make_err(op->dwError); + *op->ec_out = iocp_make_err(op->dwError, /*accept_path=*/true); else *op->ec_out = {}; } @@ -325,6 +325,10 @@ win_local_stream_acceptor_internal::wait( inline void win_local_stream_acceptor_internal::close_socket() noexcept { + // Flag the accept op cancelled before closing so a closesocket-delivered + // ERROR_NETNAME_DELETED is short-circuited to canceled rather than mapped + // to connection_aborted by iocp_make_err (see win_tcp_socket close_socket). + acc_.request_cancel(); wt_.request_cancel(); svc_.scheduler().cancel_wait_if_constructed(&wt_); diff --git a/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp b/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp index f033629af..7be8cf549 100644 --- a/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_local_stream_service.hpp @@ -659,6 +659,12 @@ win_local_stream_socket_internal::cancel() noexcept inline void win_local_stream_socket_internal::close_socket() noexcept { + // Flag every op cancelled before closing so a closesocket-delivered + // ERROR_NETNAME_DELETED is short-circuited to canceled rather than mapped + // to connection_reset by iocp_make_err (see win_tcp_socket close_socket). + conn_.request_cancel(); + rd_.request_cancel(); + wr_.request_cancel(); wt_.request_cancel(); svc_.scheduler().cancel_wait_if_constructed(&wt_); diff --git a/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp b/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp index ceebe427f..94245955b 100644 --- a/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp +++ b/include/boost/corosio/native/detail/iocp/win_overlapped_op.hpp @@ -33,6 +33,72 @@ namespace boost::corosio::detail { +/** Convert an IOCP completion's raw error to std::error_code, disambiguating + ERROR_NETNAME_DELETED by operation kind and surfacing the remote-connection + error family as portable std::errc conditions. + + IOCP delivers ERROR_NETNAME_DELETED both when a local closesocket() cancels + a pending op (already handled by the cancelled flag before this is reached) + and when the peer hard-closes with a RST. A NETNAME_DELETED that survives + the cancelled check is therefore a genuine remote reset, and the correct + surface depends on the operation: connection_reset for stream read/write, + connection_aborted for accept (mirroring Asio's per-op mapping). + + Why generic_category / std::errc rather than a native code: which raw + Windows code std::system_category maps to a given std::errc condition + depends on the standard library. MSVC's STL maps the WSA-range socket + codes (WSAECONNRESET 10054, WSAECONNREFUSED 10061, ...) to the std::errc + conditions but not their 12xx Win32 counterparts; libstdc++ (MinGW) does + the exact opposite -- it maps the Win32 12xx codes but not the WSA range. + So no single system_category code compares equal to e.g. + std::errc::connection_refused on both toolchains. Returning the condition + itself via std::make_error_code (generic_category) sidesteps the + divergence: it compares equal to the matching std::errc on every standard + library, mirroring what the POSIX backends yield from errno. The trade-off + is a generic (less Windows-specific) message string. + + Both the WSA and Win32 forms are accepted because the delivered form + varies by operation: a WSASend/WSARecv completion surfaces the Winsock + code directly (e.g. WSAECONNRESET 10054), whereas a ConnectEx failure is + completed with an NTSTATUS and GetQueuedCompletionStatus reports the Win32 + code RtlNtStatusToDosError derives from it (e.g. ERROR_CONNECTION_REFUSED + 1225). This normalization lives here, in the IOCP layer, rather than in + the platform-neutral make_err. All other codes defer to make_err. + + @param dwError The Windows error code (DWORD). + @param accept_path True on the accept completion path. + @return The corresponding std::error_code. +*/ +inline std::error_code +iocp_make_err(DWORD dwError, bool accept_path) noexcept +{ + // A pending op hit by a remote RST completes with ERROR_NETNAME_DELETED; + // its portable meaning depends on the operation (reset vs aborted). + if (dwError == ERROR_NETNAME_DELETED) + return std::make_error_code(accept_path + ? std::errc::connection_aborted + : std::errc::connection_reset); + + switch (dwError) + { + case WSAECONNRESET: // 10054 + return std::make_error_code(std::errc::connection_reset); + case WSAECONNREFUSED: case ERROR_CONNECTION_REFUSED: // 10061 / 1225 + return std::make_error_code(std::errc::connection_refused); + case WSAECONNABORTED: case ERROR_CONNECTION_ABORTED: // 10053 / 1236 + return std::make_error_code(std::errc::connection_aborted); + case WSAENETUNREACH: case ERROR_NETWORK_UNREACHABLE: // 10051 / 1231 + return std::make_error_code(std::errc::network_unreachable); + case WSAEHOSTUNREACH: case ERROR_HOST_UNREACHABLE: // 10065 / 1232 + return std::make_error_code(std::errc::host_unreachable); + case WSAETIMEDOUT: case ERROR_SEM_TIMEOUT: // 10060 / 121 + return std::make_error_code(std::errc::timed_out); + default: + break; + } + return make_err(dwError); +} + /** Base class for IOCP overlapped operations. Derives from both OVERLAPPED (for Windows IOCP) and scheduler_op @@ -117,7 +183,8 @@ struct overlapped_op decode_io_result( ec_out, cancelled.load(std::memory_order_acquire), - dwError != 0 ? make_err(dwError) : std::error_code{}, + dwError != 0 ? iocp_make_err(dwError, /*accept_path=*/false) + : std::error_code{}, is_read, static_cast(bytes_transferred), empty_buffer); diff --git a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp index 91d10e9a9..2a0d8053a 100644 --- a/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_tcp_acceptor_service.hpp @@ -1,6 +1,7 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2026 Steve Gerbino +// Copyright (c) 2026 Michael Vandeberg // // 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) @@ -235,7 +236,7 @@ accept_op::do_complete( if (op->cancelled.load(std::memory_order_acquire)) *op->ec_out = capy::error::canceled; else if (op->dwError != 0) - *op->ec_out = make_err(op->dwError); + *op->ec_out = iocp_make_err(op->dwError, /*accept_path=*/true); else *op->ec_out = {}; } @@ -774,11 +775,19 @@ win_tcp_socket_internal::cancel() noexcept inline void win_tcp_socket_internal::close_socket() noexcept { - // Tear down any aux-reactor-parked wait op before closing the - // SOCKET handle. Otherwise the reactor would keep polling a - // dangling fd (and on a Winsock SOCKET-id reuse the wrong fd - // could be polled briefly). The cancel happens-before the - // closesocket below. + // Flag every op cancelled before tearing down the handle. closesocket() + // can complete a pending overlapped op with ERROR_NETNAME_DELETED (not the + // MSDN-documented ERROR_OPERATION_ABORTED), which iocp_make_err now maps to + // connection_reset -- so a locally-closed op must be short-circuited to + // canceled via the flag rather than relying on the raw error, exactly as + // cancel() does. The store happens-before the completion is decoded. + conn_.request_cancel(); + rd_.request_cancel(); + wr_.request_cancel(); + // wt_ is parked in the aux reactor (no overlapped outstanding), so it also + // needs an explicit reactor deregister before the SOCKET handle is closed; + // otherwise the reactor would keep polling a dangling fd (and on a Winsock + // SOCKET-id reuse the wrong fd could be polled briefly). wt_.request_cancel(); svc_.scheduler().cancel_wait_if_constructed(&wt_); @@ -1433,6 +1442,10 @@ win_tcp_acceptor_internal::cancel() noexcept inline void win_tcp_acceptor_internal::close_socket() noexcept { + // Flag the accept op cancelled before closing so a closesocket-delivered + // ERROR_NETNAME_DELETED is short-circuited to canceled rather than mapped + // to connection_aborted by iocp_make_err (see win_tcp_socket close_socket). + acc_.request_cancel(); // Tear down any aux-reactor-parked wait op first. wt_.request_cancel(); svc_.scheduler().cancel_wait_if_constructed(&wt_); diff --git a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp index 4ceef79a8..69e15a87f 100644 --- a/include/boost/corosio/native/detail/iocp/win_udp_service.hpp +++ b/include/boost/corosio/native/detail/iocp/win_udp_service.hpp @@ -745,6 +745,14 @@ win_udp_socket_internal::cancel() noexcept inline void win_udp_socket_internal::close_socket() noexcept { + // Flag every op cancelled before closing so a closesocket-delivered + // ERROR_NETNAME_DELETED is short-circuited to canceled rather than mapped + // to connection_reset by iocp_make_err (see win_tcp_socket close_socket). + wr_.request_cancel(); + rd_.request_cancel(); + conn_.request_cancel(); + send_wr_.request_cancel(); + recv_rd_.request_cancel(); wt_.request_cancel(); svc_.scheduler().cancel_wait_if_constructed(&wt_); diff --git a/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp b/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp index 4a734ed71..0cf8b6dc0 100644 --- a/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp +++ b/include/boost/corosio/native/detail/kqueue/kqueue_traits.hpp @@ -45,18 +45,16 @@ struct kqueue_traits static constexpr bool needs_write_notification = false; - /* macOS kqueue workaround: RST doesn't reliably trigger EV_EOF. - If the user sets SO_LINGER, we clear it before close so the - destructor doesn't block and close() sends FIN instead of RST. - - The hook tracks whether the user explicitly set SO_LINGER via - set_option(). On pre_shutdown/pre_destroy, if the flag is set, - we reset linger to off before the fd is closed. - */ + // No per-socket state or lifecycle hooks: the stream socket hook is a + // plain setsockopt passthrough, like epoll/select. SO_LINGER in particular + // is passed through unmodified. An abortive close (SO_LINGER{on,0}) thus + // sends a RST, which the peer's kqueue reports as EV_EOF carrying + // ECONNRESET in fflags -- so the peer surfaces connection_reset rather than + // a graceful eof, consistent with the other backends (#304). A positive + // linger timeout is likewise honored, so close() with unsent data blocks + // up to that timeout, as POSIX specifies. struct stream_socket_hook { - bool user_set_linger_ = false; - std::error_code on_set_option( int fd, int level, int optname, void const* data, std::size_t size) noexcept @@ -65,37 +63,10 @@ struct kqueue_traits fd, level, optname, data, static_cast(size)) != 0) return make_err(errno); - - if (level == SOL_SOCKET && optname == SO_LINGER && - size >= sizeof(struct ::linger)) - user_set_linger_ = - static_cast(data)->l_onoff != 0; - return {}; } - - void pre_shutdown(int fd) noexcept - { - reset_linger(fd); - } - - void pre_destroy(int fd) noexcept - { - reset_linger(fd); - } - - private: - void reset_linger(int fd) noexcept - { - if (user_set_linger_ && fd >= 0) - { - struct ::linger lg; - lg.l_onoff = 0; - lg.l_linger = 0; - ::setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg)); - } - user_set_linger_ = false; - } + static void pre_shutdown(int) noexcept {} + static void pre_destroy(int) noexcept {} }; struct write_policy diff --git a/include/boost/corosio/native/detail/make_err.hpp b/include/boost/corosio/native/detail/make_err.hpp index db994c15d..bc92055ae 100644 --- a/include/boost/corosio/native/detail/make_err.hpp +++ b/include/boost/corosio/native/detail/make_err.hpp @@ -1,6 +1,7 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2026 Steve Gerbino +// Copyright (c) 2026 Michael Vandeberg // // 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) @@ -52,13 +53,14 @@ make_err(int errn) noexcept /** Convert a Windows error code to std::error_code. - Maps ERROR_OPERATION_ABORTED, ERROR_CANCELLED, and - ERROR_NETNAME_DELETED to capy::error::canceled. - Maps ERROR_HANDLE_EOF to capy::error::eof. + Maps ERROR_OPERATION_ABORTED and ERROR_CANCELLED to + capy::error::canceled, and ERROR_HANDLE_EOF to capy::error::eof. + Every other code passes through std::system_category(). - ERROR_NETNAME_DELETED (64) is what IOCP actually delivers - when closesocket() cancels pending overlapped I/O, despite - MSDN documenting ERROR_OPERATION_ABORTED for that case. + ERROR_NETNAME_DELETED (64) is deliberately not mapped here: IOCP + delivers it both for a local closesocket() that cancels pending I/O + and for a remote RST, so it can only be disambiguated per operation + kind at the IOCP decode sites (see iocp_make_err in win_overlapped_op). @param dwError The Windows error code (DWORD). @return The corresponding std::error_code. @@ -69,8 +71,7 @@ make_err(unsigned long dwError) noexcept if (dwError == 0) return {}; - if (dwError == ERROR_OPERATION_ABORTED || dwError == ERROR_CANCELLED || - dwError == ERROR_NETNAME_DELETED) + if (dwError == ERROR_OPERATION_ABORTED || dwError == ERROR_CANCELLED) return capy::error::canceled; if (dwError == ERROR_HANDLE_EOF) diff --git a/include/boost/corosio/native/detail/reactor/reactor_socket_service.hpp b/include/boost/corosio/native/detail/reactor/reactor_socket_service.hpp index cdae31f6f..b862a32c6 100644 --- a/include/boost/corosio/native/detail/reactor/reactor_socket_service.hpp +++ b/include/boost/corosio/native/detail/reactor/reactor_socket_service.hpp @@ -117,7 +117,8 @@ class reactor_socket_service : public ServiceBase } protected: - // Override in derived to add pre-close logic (e.g. kqueue linger reset) + // Override in derived to add pre-close logic. No backend currently needs + // it; the hooks exist so a trait can run fd-level teardown before close. void pre_shutdown(Impl*) noexcept {} void pre_destroy(Impl*) noexcept {} diff --git a/test/unit/error_conditions.cpp b/test/unit/error_conditions.cpp new file mode 100644 index 000000000..2ce8a2f17 --- /dev/null +++ b/test/unit/error_conditions.cpp @@ -0,0 +1,219 @@ +// +// Copyright (c) 2026 Michael Vandeberg +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include "context.hpp" +#include "test_suite.hpp" + +/* + Cross-backend error-condition consistency audit (issue #304). + + The remote/connection error family (peer RST, refused connect, write to a + dead peer) is the class whose native mapping diverges per platform, so each + scenario here is exercised under *every* backend via COROSIO_BACKEND_TESTS + and asserts a portable capy::cond / std::errc condition. The contract: + + FIN (graceful close) -> cond::eof + RST (hard close) -> errc::connection_reset (NOT cond::canceled) + connect refused -> errc::connection_refused + write to dead peer -> errc::connection_reset | broken_pipe + + #304: on Windows IOCP a remote RST used to surface as cond::canceled, + conflating remote termination with local cancellation. cond::canceled must + mean only local, deliberate cancellation (stop token / cancel() / close()) -- + that family is already covered across all backends elsewhere (tcp_socket, + tcp_acceptor, connect, udp_socket, precancel, datagram_paths) and is not + duplicated here. + + Not covered (intentional): accept of an aborted connection -> + connection_aborted. It cannot be forced deterministically across platforms + (the client must RST between the SYN and accept completing), so no hard test + exists for it; the IOCP accept path maps it in win_*_acceptor_service. +*/ + +namespace boost::corosio { + +template +struct error_conditions_test +{ + // FIN: a graceful peer close surfaces as end-of-file. Anchor that contrasts + // with the RST case below -- #304 is about keeping the two distinct. + void testReadFinReturnsEof() + { + io_context ioc(Backend); + // Linger=false => graceful FIN on close. + auto [s1, s2] = + test::make_socket_pair(ioc); + + std::error_code read_ec; + bool read_done = false; + + auto reader = [&](tcp_socket& b) -> capy::task<> { + char buf[32] = {}; + auto [ec, n] = + co_await b.read_some(capy::mutable_buffer(buf, sizeof(buf))); + read_ec = ec; + read_done = true; + (void)n; + }; + auto closer = [](tcp_socket& a) -> capy::task<> { + a.close(); // graceful FIN + co_return; + }; + + capy::run_async(ioc.get_executor())(reader(s2)); + capy::run_async(ioc.get_executor())(closer(s1)); + ioc.run(); + + BOOST_TEST(read_done); + BOOST_TEST(read_ec == capy::cond::eof); + + s1.close(); + s2.close(); + } + + // RST: a remote hard close surfaces as connection_reset -- never canceled + // (the #304 regression) and never eof. + void testReadResetReturnsConnectionReset() + { + io_context ioc(Backend); + // Linger=true (default) => SO_LINGER{on,0}; close() aborts with a RST. + auto [s1, s2] = test::make_socket_pair(ioc); + + std::error_code read_ec; + bool read_done = false; + + // Reader parks with no data, so the RST lands on a pending op -- the + // exact IOCP scenario that yields ERROR_NETNAME_DELETED. + auto reader = [&](tcp_socket& b) -> capy::task<> { + char buf[32] = {}; + auto [ec, n] = + co_await b.read_some(capy::mutable_buffer(buf, sizeof(buf))); + read_ec = ec; + read_done = true; + (void)n; + }; + auto closer = [](tcp_socket& a) -> capy::task<> { + a.close(); // RST via SO_LINGER{on,0} + co_return; + }; + + capy::run_async(ioc.get_executor())(reader(s2)); + capy::run_async(ioc.get_executor())(closer(s1)); + ioc.run(); + + BOOST_TEST(read_done); + BOOST_TEST(read_ec == std::errc::connection_reset); + BOOST_TEST(read_ec != capy::cond::canceled); + BOOST_TEST(read_ec != capy::cond::eof); + + s1.close(); + s2.close(); + } + + // Writing to a peer that has died surfaces as connection_reset or + // broken_pipe (which one depends on OS/timing), never canceled. + void testWriteToResetPeerReturnsResetOrBrokenPipe() + { + io_context ioc(Backend); + // Linger=true (default): the closed peer sends a RST. + auto [s1, s2] = test::make_socket_pair(ioc); + + std::error_code write_ec; + bool write_failed = false; + + auto task = [&](tcp_socket& a, tcp_socket& b) -> capy::task<> { + b.close(); // peer dies (RST) + + // Let the RST propagate before writing. + (void)co_await corosio::delay(std::chrono::milliseconds(50)); + + // Keep writing until the failure surfaces. The budget (256 x 64 KiB + // = 16 MiB) is far beyond any platform's send buffer + in-flight + // window (macOS buffers larger than Linux), so a dead peer must + // error well within it; if it somehow does not, write_failed stays + // false and the assertion below reports that directly. + std::array buf{}; + for (int i = 0; i < 256; ++i) + { + auto [ec, n] = co_await a.write_some( + capy::const_buffer(buf.data(), buf.size())); + write_ec = ec; + (void)n; + if (ec) + { + write_failed = true; + break; + } + } + }; + + capy::run_async(ioc.get_executor())(task(s1, s2)); + ioc.run(); + + BOOST_TEST(write_failed); + BOOST_TEST( + write_ec == std::errc::connection_reset || + write_ec == std::errc::broken_pipe); + BOOST_TEST(write_ec != capy::cond::canceled); + + s1.close(); + s2.close(); + } + + // Connecting to a closed port surfaces as connection_refused, never canceled. + void testConnectRefusedReturnsConnectionRefused() + { + io_context ioc(Backend); + tcp_socket sock(ioc); + sock.open(); + + std::error_code connect_ec; + + auto task = [&]() -> capy::task<> { + // Port 1 has no listener on loopback => refused. + auto [ec] = + co_await sock.connect(endpoint(ipv4_address::loopback(), 1)); + connect_ec = ec; + }; + + capy::run_async(ioc.get_executor())(task()); + ioc.run(); + + BOOST_TEST(connect_ec == std::errc::connection_refused); + BOOST_TEST(connect_ec != capy::cond::canceled); + + sock.close(); + } + + void run() + { + testReadFinReturnsEof(); + testReadResetReturnsConnectionReset(); + testWriteToResetPeerReturnsResetOrBrokenPipe(); + testConnectRefusedReturnsConnectionRefused(); + } +}; + +COROSIO_BACKEND_TESTS(error_conditions_test, "boost.corosio.error_conditions") + +} // namespace boost::corosio