Skip to content

IOCP: ERROR_NETNAME_DELETED from remote RST is misreported as cond::canceled #304

Description

@sgerbino

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

  1. Remove ERROR_NETNAME_DELETED from the canceled mapping in make_err.
  2. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions