feat(macos): validate dev + apple-dev presets on macOS, fix 3 cross-platform bugs

macOS port groundwork — core, server, tools, and tests now build and run on
macOS 26.5 / Apple Silicon. ctest --preset dev green 21/21 (2 consecutive runs).
apple-dev produces valid arm64 libvoicecat.a + XCFramework for the Swift Package.

Three real cross-platform bugs found and fixed (all latent on Windows/Linux):

1. test_m2_voice.cpp POSIX branch missing <netdb.h> — Linux glibc transitively
   includes it, macOS doesn't. Would fail on any strict POSIX system.

2. SIGPIPE killing processes on macOS — writing to a closed TCP socket raises
   SIGPIPE by default (doesn't exist on Windows, benign on Linux). Fixed by
   ignoring SIGPIPE in both core client init and server startup (POSIX-only,
   #ifndef _WIN32). Production fix, not just tests.

3. Use-after-free of Asio's kqueue reactor on server shutdown — the
   deterministic test_tofu_flow segfault. TcpServerConn's tls_read_loop runs on
   a blocking-I/O thread; when Server::run() returned, io_context was destroyed
   while those threads were still running. On macOS kqueue the reactor pointer
   is null'd immediately -> segfault in socket.close(). Latent on Windows IOCP
   and Linux epoll. Fix: TcpAcceptor now tracks connections; new shutdown()
   closes all + joins threads before io is destroyed; Server::stop() now closes
   acceptor + media_relay too (was just io.stop()).

Verified: dev + apple-dev presets build green, 21/21 tests pass, server starts
+ two vccli text chat over TLS (M1 on Mac), vccli --voice starts MIC stream via
CoreAudio (M2 protocol-level), vccli --list-devices enumerates CoreAudio
devices, xcodebuild -create-xcframework produces valid VoiceCatCore.xcframework.

No ABI or proto changes. Docs updated: building.md, clients/apple/README.md,
PROGRESS.md, CLAUDE.md status line.
This commit is contained in:
2026-06-18 13:24:42 +02:00
parent bcb7ae8ccb
commit b2af1a3001
9 changed files with 187 additions and 16 deletions

View File

@@ -322,6 +322,17 @@ void TcpServerConn::close() {
// In non-TLS mode, the Asio async chain will naturally stop when the socket closes.
}
void TcpServerConn::wait_closed() {
if (tls_thread_.joinable()) {
if (std::this_thread::get_id() == tls_thread_.get_id()) {
// Being called from our own TLS thread — detach to avoid self-join deadlock.
tls_thread_.detach();
} else {
tls_thread_.join();
}
}
}
// ── TcpAcceptor ─────────────────────────────────────────────────────────────
namespace {
@@ -359,6 +370,33 @@ void TcpAcceptor::stop() {
stopped_ = true;
std::error_code ignored;
acceptor_.close(ignored);
// Close all tracked connections so their TLS read threads exit. The socket close
// happens while the io_context (and its reactor) is still alive, preventing the
// null-reactor use-after-free that manifests on macOS kqueue.
std::vector<std::shared_ptr<TcpServerConn>> to_close;
{
std::lock_guard lk(conns_mu_);
to_close = conns_;
}
for (auto& conn : to_close) conn->close();
}
void TcpAcceptor::shutdown() {
stop();
// Wait for every connection's TLS I/O thread to finish. close() (called by stop())
// set closing_=true and closed the socket, so tls_read_loop is already exiting or has
// exited; the join is brief. This must complete BEFORE the io_context is destroyed.
std::vector<std::shared_ptr<TcpServerConn>> to_join;
{
std::lock_guard lk(conns_mu_);
to_join = std::move(conns_);
}
for (auto& conn : to_join) {
conn->wait_closed();
}
// to_join drops here — if a thread captured shared_from_this, the TcpServerConn stays
// alive until that thread releases it; the destructor's close() is a no-op (already
// closed) and tls_thread_ is already joined, so no reactor access occurs.
}
void TcpAcceptor::do_accept() {
@@ -371,7 +409,14 @@ void TcpAcceptor::do_accept() {
}
socket.set_option(asio::ip::tcp::no_delay(true));
auto conn = factory_(std::move(socket));
if (conn) conn->start();
if (conn) {
conn->start();
// Track so shutdown() can close + join before the io_context is destroyed.
{
std::lock_guard lk(conns_mu_);
conns_.push_back(conn);
}
}
do_accept();
});
}