Summary
On Windows, detail::make_err unconditionally maps ERROR_NETNAME_DELETED to capy::error::canceled:
// include/boost/corosio/native/detail/make_err.hpp
if (dwError == ERROR_OPERATION_ABORTED || dwError == ERROR_CANCELLED ||
dwError == ERROR_NETNAME_DELETED)
return capy::error::canceled;
ERROR_NETNAME_DELETED (64) is delivered by IOCP not only when pending overlapped I/O is cancelled by a local closesocket() (as the comment in make_err.hpp notes), but also when the remote peer hard-closes the connection (RST). With the current mapping, a remote reset on Windows surfaces as cond::canceled, conflating "the peer killed the connection" with "this operation was deliberately cancelled locally."
This came up in the Boost review discussion of the cond::canceled semantics: the design contract is that cond::canceled means local, deliberate cancellation, and remote termination should surface as connection_reset. POSIX already behaves correctly; Windows currently does not.
POSIX behavior (verified, correct)
The POSIX make_err passes everything except ECANCELED through std::system_category(). Verified empirically on Linux (io_uring backend) with a peer that closes using SO_LINGER{1,0} so the kernel sends RST while the corosio client is parked in read_some():
--- remote RST (SO_LINGER abort) ---
value: 104 (ECONNRESET), category: system
== cond::canceled: false
== errc::connection_reset: true
--- remote graceful close (FIN) ---
== cond::eof: true
How Asio handles the same ambiguity
Asio documents this exact problem (win_iocp_socket_service_base.hpp): MSDN says locally closed sockets complete pending ops with ERROR_OPERATION_ABORTED, but in practice they complete with ERROR_NETNAME_DELETED, so the raw code cannot distinguish local close from remote reset. Asio disambiguates with a per-socket cancel token, in socket_ops::complete_iocp_recv:
if (ec.value() == ERROR_NETNAME_DELETED)
{
if (cancel_token.expired()) // socket was locally closed
ec = asio::error::operation_aborted;
else // peer hard-closed: RST
ec = asio::error::connection_reset;
}
Note the mapping is operation-dependent: for accept Asio maps ERROR_NETNAME_DELETED to connection_aborted, and it maps ERROR_PORT_UNREACHABLE to connection_refused.
Corosio already has the disambiguation state
Every overlapped_op carries a cancelled atomic flag set by all three local-cancellation paths: stop token (via on_cancel()), close(), and cancel() (win_overlapped_op.hpp). And decode_io_result (coro_op_complete.hpp) consumes it before the raw error:
if (cancelled)
*ec_out = capy::error::canceled; // local cancellation handled here
else if (err)
*ec_out = err; // ERROR_NETNAME_DELETED reaches here
// only when nothing local cancelled it
So by the time make_err(dwError) is consulted, the local case has already been taken by the cancelled branch. The per-op flag is more precise than Asio's per-socket token, which means no new state is needed.
Proposed fix
- Remove
ERROR_NETNAME_DELETED from the canceled mapping in make_err.
- Map it to
{WSAECONNRESET, std::system_category()} for stream read/write completions (compares equal to std::errc::connection_reset, matching the POSIX behavior) and to connection_aborted on the accept path.
Since the correct mapping depends on the operation kind, the remap belongs in the per-backend decode step rather than in the generic make_err. The comment block in coro_op_complete.hpp notes the decode step is planned to be formalized as Traits::decode_result, which would be the natural home.
Impact
- After the fix,
cond::canceled on Windows means exactly what it means on POSIX: this operation stopped because something on this end asked it to (stop token, cancel(), or close()). A caller who does sock.close() from another task still gets cond::canceled via the cancelled flag; only the genuinely remote case changes.
- Related:
normalize_openssl_shutdown_read_error in openssl_stream.cpp also folds canceled, connection_reset, and connection_aborted into stream_truncated during shutdown reads, which was separately acknowledged in the review discussion as too aggressive. Fixing both restores the invariant that remote termination is distinguishable from local cancellation on every backend.
Summary
On Windows,
detail::make_errunconditionally mapsERROR_NETNAME_DELETEDtocapy::error::canceled:ERROR_NETNAME_DELETED(64) is delivered by IOCP not only when pending overlapped I/O is cancelled by a localclosesocket()(as the comment inmake_err.hppnotes), but also when the remote peer hard-closes the connection (RST). With the current mapping, a remote reset on Windows surfaces ascond::canceled, conflating "the peer killed the connection" with "this operation was deliberately cancelled locally."This came up in the Boost review discussion of the
cond::canceledsemantics: the design contract is thatcond::canceledmeans local, deliberate cancellation, and remote termination should surface asconnection_reset. POSIX already behaves correctly; Windows currently does not.POSIX behavior (verified, correct)
The POSIX
make_errpasses everything exceptECANCELEDthroughstd::system_category(). Verified empirically on Linux (io_uring backend) with a peer that closes usingSO_LINGER{1,0}so the kernel sends RST while the corosio client is parked inread_some():How Asio handles the same ambiguity
Asio documents this exact problem (
win_iocp_socket_service_base.hpp): MSDN says locally closed sockets complete pending ops withERROR_OPERATION_ABORTED, but in practice they complete withERROR_NETNAME_DELETED, so the raw code cannot distinguish local close from remote reset. Asio disambiguates with a per-socket cancel token, insocket_ops::complete_iocp_recv:Note the mapping is operation-dependent: for
acceptAsio mapsERROR_NETNAME_DELETEDtoconnection_aborted, and it mapsERROR_PORT_UNREACHABLEtoconnection_refused.Corosio already has the disambiguation state
Every
overlapped_opcarries acancelledatomic flag set by all three local-cancellation paths: stop token (viaon_cancel()),close(), andcancel()(win_overlapped_op.hpp). Anddecode_io_result(coro_op_complete.hpp) consumes it before the raw error:So by the time
make_err(dwError)is consulted, the local case has already been taken by thecancelledbranch. The per-op flag is more precise than Asio's per-socket token, which means no new state is needed.Proposed fix
ERROR_NETNAME_DELETEDfrom thecanceledmapping inmake_err.{WSAECONNRESET, std::system_category()}for stream read/write completions (compares equal tostd::errc::connection_reset, matching the POSIX behavior) and toconnection_abortedon the accept path.Since the correct mapping depends on the operation kind, the remap belongs in the per-backend decode step rather than in the generic
make_err. The comment block incoro_op_complete.hppnotes the decode step is planned to be formalized asTraits::decode_result, which would be the natural home.Impact
cond::canceledon Windows means exactly what it means on POSIX: this operation stopped because something on this end asked it to (stop token,cancel(), orclose()). A caller who doessock.close()from another task still getscond::canceledvia thecancelledflag; only the genuinely remote case changes.normalize_openssl_shutdown_read_errorinopenssl_stream.cppalso foldscanceled,connection_reset, andconnection_abortedintostream_truncatedduring shutdown reads, which was separately acknowledged in the review discussion as too aggressive. Fixing both restores the invariant that remote termination is distinguishable from local cancellation on every backend.