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:
@@ -24,6 +24,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
|
||||
@@ -172,6 +173,12 @@ void vc_client::run_io(std::string host, uint16_t port) {
|
||||
#ifdef _WIN32
|
||||
WSADATA wsa{};
|
||||
WSAStartup(MAKEWORD(2, 2), &wsa);
|
||||
#else
|
||||
// POSIX/macOS: ignore SIGPIPE — a write to a closed socket returns EPIPE instead of
|
||||
// terminating the process. On macOS SIGPIPE is delivered by default (unlike Windows
|
||||
// where it doesn't exist); without this, a peer dropping mid-TLS-write kills us.
|
||||
// Process-global and idempotent (safe to call per run_io).
|
||||
std::signal(SIGPIPE, SIG_IGN);
|
||||
#endif
|
||||
|
||||
struct addrinfo hints{};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ class TcpServerConn : public std::enable_shared_from_this<TcpServerConn> {
|
||||
// Close the connection (safe from any thread).
|
||||
void close();
|
||||
|
||||
// Block until the TLS I/O thread (if any) has finished. Must be called after close().
|
||||
// Safe to call from any thread except the TLS I/O thread itself.
|
||||
void wait_closed();
|
||||
|
||||
bool connected() const { return connected_.load(std::memory_order_acquire); }
|
||||
|
||||
private:
|
||||
@@ -155,18 +159,28 @@ class TcpAcceptor {
|
||||
// Start accepting. Call once; re-arms itself automatically.
|
||||
void start();
|
||||
|
||||
// Stop accepting (does not close existing connections).
|
||||
// Stop accepting and close all tracked connections (safe while io_context is alive).
|
||||
void stop();
|
||||
|
||||
// Stop accepting, close all connections, and block until every connection's
|
||||
// I/O thread has finished. Call BEFORE the io_context is destroyed — the TLS read
|
||||
// threads do blocking I/O (not async on the io_context) and will touch the reactor
|
||||
// on socket close if the io_context is already gone (manifests as a null-kqueue-reactor
|
||||
// segfault on macOS; latent on Windows IOCP / Linux epoll where timing is more forgiving).
|
||||
void shutdown();
|
||||
|
||||
// Actual bound port (useful when bind_port=0 lets the OS pick).
|
||||
uint16_t local_port() const { return static_cast<uint16_t>(acceptor_.local_endpoint().port()); }
|
||||
|
||||
private:
|
||||
void do_accept();
|
||||
|
||||
asio::ip::tcp::acceptor acceptor_;
|
||||
ConnFactory factory_;
|
||||
bool stopped_{false};
|
||||
asio::ip::tcp::acceptor acceptor_;
|
||||
ConnFactory factory_;
|
||||
bool stopped_{false};
|
||||
|
||||
std::mutex conns_mu_;
|
||||
std::vector<std::shared_ptr<TcpServerConn>> conns_;
|
||||
};
|
||||
|
||||
// ── UDP media channel (M2) ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user