Verification status
Reproduced. Measured on macOS (Darwin 25.6.0, arm64, Apple clang 17 via Xcode's toolchain), while working the framework coverage/mutation improvement plan (docs/superpowers/plans/2026-09-03-framework-coverage-and-mutation.md), during a full ctest --preset clang-coverage run. Not verified on Linux (the CI baseline platform) — the root cause below explains why this is expected to be macOS/BSD-specific and not reproduce there.
Symptom
ctest --preset clang-coverage (with stopOnFailure: true) stalled at test SocketBackend: a plain handler keeps its own instance over the wire (tests/net/test_socket_backend.cpp:238) until ctest's 120s per-test timeout killed it. Re-running with that one test excluded reproduced an identical timeout on the very next SocketBackend: test (a reconnect handler that re-registers does not deadlock the transport), confirming this is systemic rather than a one-off flake.
Every test that constructs a morph::net::SocketServer and lets it go out of scope hits this — 21 tests total (13 in tests/net/test_socket_backend.cpp, 8 in tests/net/test_socket_server.cpp, 1 in tests/net_qt_interop/test_net_qt_interop.cpp — the interop test that connects to a morph::net::SocketServer; the sibling interop test that connects to a QtWebSocketServer is unaffected).
Root cause (confirmed with sample(1) on the hung process)
Thread_958110 (main): CATCH2_INTERNAL_TEST_21() [test_socket_backend.cpp:270]
-> SocketServer::~SocketServer() [socket_server.hpp:63]
-> SocketServer::close() [socket_server.hpp:93]
-> std::thread::join() (blocked in __ulock_wait)
Thread_958113 (the server's accept-loop thread):
SocketServer::listen()::lambda -> acceptLoop() [socket_server.hpp:154]
-> TcpSocket::accept() [tcp_socket.hpp:176]
-> ::accept() (blocked in the accept(2) syscall)
SocketServer::close() (include/morph/net/socket_server.hpp:84-114) calls _listenSocket.shutdownBoth() — ::shutdown(fd, SHUT_RDWR) (include/morph/net/detail/tcp_socket.hpp:238-240) — specifically to unblock a thread parked in a blocking accept(), then joins that thread. The code's own comment at tcp_socket.hpp:169 documents this as relying on "a concurrent shutdownBoth() from another thread unblocks a pending accept()." That holds on Linux (this repo's CI baseline platform). On macOS/BSD kernels, shutdown(2) on a listening (unconnected) socket is a well-known no-op for waking a peer thread blocked in accept() — the accept-loop thread never wakes, the join never returns, and the destructor hangs forever (there is no timeout on the join itself; only an external per-test timeout eventually kills the process).
This is a portability bug in the shutdown-based accept()-unblock strategy, not a logic error in the test suite and not evidence of a coverage gap — every affected test's own assertions pass; the hang is entirely in post-test C++ object teardown.
What would change the verdict
- If someone reproduces this on Linux (the CI platform), the "macOS/BSD-specific" framing would need revising.
- Fixed once
SocketServer::close()'s teardown path reliably unblocks the accept-loop thread on macOS — e.g. via a self-pipe/eventfd-style wakeup, a non-blocking accept() with a poll/kqueue wait, or a platform-conditional close-then-join (closing the listening fd itself, not just shutting it down, before join, is the usual portable fix — close(2) on the fd a blocking accept() holds does unblock it with EBADF on both Linux and macOS/BSD, unlike shutdown(2)).
Scope of impact
Framework-only, narrow: include/morph/net/socket_server.hpp and include/morph/net/detail/tcp_socket.hpp. Does not affect morph::qt::QtWebSocketServer/QtWebSocketBackend (a separate, Qt-event-loop-driven implementation — all of those tests, including ones that also tear down servers mid-test, passed without issue on the same machine, which independently corroborates the bug is specific to the raw blocking-accept()-thread design in SocketServer).
Practical consequence: nobody can run the full tests/net suite to completion on macOS today without hitting this; ctest -E "^(SocketServer:|SocketBackend:|QtWebSocketBackend interop: connects to a SocketServer)" is a working local exclusion in the meantime.
Verification status
Reproduced. Measured on macOS (Darwin 25.6.0, arm64, Apple clang 17 via Xcode's toolchain), while working the framework coverage/mutation improvement plan (
docs/superpowers/plans/2026-09-03-framework-coverage-and-mutation.md), during a fullctest --preset clang-coveragerun. Not verified on Linux (the CI baseline platform) — the root cause below explains why this is expected to be macOS/BSD-specific and not reproduce there.Symptom
ctest --preset clang-coverage(withstopOnFailure: true) stalled at testSocketBackend: a plain handler keeps its own instance over the wire(tests/net/test_socket_backend.cpp:238) until ctest's 120s per-test timeout killed it. Re-running with that one test excluded reproduced an identical timeout on the very nextSocketBackend:test (a reconnect handler that re-registers does not deadlock the transport), confirming this is systemic rather than a one-off flake.Every test that constructs a
morph::net::SocketServerand lets it go out of scope hits this — 21 tests total (13 intests/net/test_socket_backend.cpp, 8 intests/net/test_socket_server.cpp, 1 intests/net_qt_interop/test_net_qt_interop.cpp— the interop test that connects to amorph::net::SocketServer; the sibling interop test that connects to aQtWebSocketServeris unaffected).Root cause (confirmed with
sample(1)on the hung process)SocketServer::close()(include/morph/net/socket_server.hpp:84-114) calls_listenSocket.shutdownBoth()—::shutdown(fd, SHUT_RDWR)(include/morph/net/detail/tcp_socket.hpp:238-240) — specifically to unblock a thread parked in a blockingaccept(), then joins that thread. The code's own comment attcp_socket.hpp:169documents this as relying on "a concurrentshutdownBoth()from another thread unblocks a pending accept()." That holds on Linux (this repo's CI baseline platform). On macOS/BSD kernels,shutdown(2)on a listening (unconnected) socket is a well-known no-op for waking a peer thread blocked inaccept()— the accept-loop thread never wakes, the join never returns, and the destructor hangs forever (there is no timeout on the join itself; only an external per-test timeout eventually kills the process).This is a portability bug in the shutdown-based accept()-unblock strategy, not a logic error in the test suite and not evidence of a coverage gap — every affected test's own assertions pass; the hang is entirely in post-test C++ object teardown.
What would change the verdict
SocketServer::close()'s teardown path reliably unblocks the accept-loop thread on macOS — e.g. via a self-pipe/eventfd-style wakeup, a non-blockingaccept()with a poll/kqueue wait, or a platform-conditional close-then-join (closing the listening fd itself, not just shutting it down, before join, is the usual portable fix —close(2)on the fd a blockingaccept()holds does unblock it withEBADFon both Linux and macOS/BSD, unlikeshutdown(2)).Scope of impact
Framework-only, narrow:
include/morph/net/socket_server.hppandinclude/morph/net/detail/tcp_socket.hpp. Does not affectmorph::qt::QtWebSocketServer/QtWebSocketBackend(a separate, Qt-event-loop-driven implementation — all of those tests, including ones that also tear down servers mid-test, passed without issue on the same machine, which independently corroborates the bug is specific to the raw blocking-accept()-thread design inSocketServer).Practical consequence: nobody can run the full
tests/netsuite to completion on macOS today without hitting this;ctest -E "^(SocketServer:|SocketBackend:|QtWebSocketBackend interop: connects to a SocketServer)"is a working local exclusion in the meantime.