Split out of #498, which bundled it with an unrelated resource leak. Filed separately because the fix, the file and the severity are all different.
Summary
~SocketBackend acquires _socketMtx before calling shutdownBoth(). sendFrame holds that same mutex across a blocking, un-timed sendAll. Against a stalled peer the destructor blocks on the very lock whose holder only shutdownBoth() would release.
Verification status
Inferred from reading the code; not reproduced. I did not construct a stalled-peer teardown. Revision: origin/master adfe8e5f plus doc-only commits.
socket_backend.hpp:89-93:
std::scoped_lock lock{_socketMtx};
if (_socket.valid()) { _socket.shutdownBoth(); }
sendFrame (:380-387) takes _socketMtx and then calls _socket.sendAll(frame.data(), frame.size()); sendAll (tcp_socket.hpp:327-339) loops on a blocking ::send with no timeout.
Why this is a defect and not a design choice
The sibling class states the trap and deliberately does the opposite. SocketServer::close() (socket_server.hpp:174-183):
"Taking it here would deadlock exactly when the shutdown is most needed: a client thread blocked in sendAll against a stalled peer's full socket buffer holds writeMtx for as long as that send is stuck, and close() has no timeout — it is reached from the destructor. shutdownBoth() is documented safe from any thread and is itself the mechanism that unblocks that send … Locking to 'protect' the socket would therefore wait on the very thing it is trying to interrupt."
Every clause applies to ~SocketBackend. shutdownBoth() is documented safe from any thread (tcp_socket.hpp:341-342), so the lock buys nothing that the I/O thread's ownership of _socket's writes does not already give.
The I/O thread's own Pong/Close echo in drainFrames (socket_backend.hpp:583, :589) reaches sendFrame too, so the holder need not even be an application thread.
What would change the verdict
- Close it if
sendFrame is shown unreachable concurrently with ~SocketBackend.
- Raise it by reproducing: a peer that accepts and never reads, filling the send buffer, then destroy the backend.
A regression test must actually stall the peer — a test that destroys an idle backend would pass with or without the fix.
Split out of #498, which bundled it with an unrelated resource leak. Filed separately because the fix, the file and the severity are all different.
Summary
~SocketBackendacquires_socketMtxbefore callingshutdownBoth().sendFrameholds that same mutex across a blocking, un-timedsendAll. Against a stalled peer the destructor blocks on the very lock whose holder onlyshutdownBoth()would release.Verification status
Inferred from reading the code; not reproduced. I did not construct a stalled-peer teardown. Revision:
origin/masteradfe8e5fplus doc-only commits.socket_backend.hpp:89-93:std::scoped_lock lock{_socketMtx}; if (_socket.valid()) { _socket.shutdownBoth(); }sendFrame(:380-387) takes_socketMtxand then calls_socket.sendAll(frame.data(), frame.size());sendAll(tcp_socket.hpp:327-339) loops on a blocking::sendwith no timeout.Why this is a defect and not a design choice
The sibling class states the trap and deliberately does the opposite.
SocketServer::close()(socket_server.hpp:174-183):Every clause applies to
~SocketBackend.shutdownBoth()is documented safe from any thread (tcp_socket.hpp:341-342), so the lock buys nothing that the I/O thread's ownership of_socket's writes does not already give.The I/O thread's own Pong/Close echo in
drainFrames(socket_backend.hpp:583,:589) reachessendFrametoo, so the holder need not even be an application thread.What would change the verdict
sendFrameis shown unreachable concurrently with~SocketBackend.A regression test must actually stall the peer — a test that destroys an idle backend would pass with or without the fix.