Skip to content

morph::net::SocketServer teardown hangs on macOS/BSD: shutdown() doesn't unblock a peer thread's blocking accept() #437

Description

@Yaraslaut

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions