diff --git a/CLAUDE.md b/CLAUDE.md index de87771..9a09524 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,9 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`]( > **One-line status:** M5 (moderation & admin UI) is complete — permissions, kick/ban/move, > server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows -> WinForms C# client is shipped (M4). `ctest --preset dev` green — 21/21 tests. macOS/iOS -> Swift client is next. See [`PROGRESS.md`](PROGRESS.md). +> WinForms C# client is shipped (M4). **macOS port validated** — `dev` + `apple-dev` presets +> build green, 21/21 tests pass on macOS. `ctest --preset dev` green — 21/21 tests. +> macOS/iOS Swift client is next. See [`PROGRESS.md`](PROGRESS.md). VoiceCat = self-hosted native voice & text chat (TeamSpeak/Mumble-style). Plain TCP (control) + UDP (media), no WebRTC, encrypted by default. A shared C++ core (`libvoicecat`) drives diff --git a/PROGRESS.md b/PROGRESS.md index a3bc338..412936b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,73 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done:** **macOS port — `dev` + `apple-dev` presets validated** (2026-06-18). The core, + server, tools, and tests now build and run on macOS (Apple Silicon, macOS 26.5, Apple clang + 21). This lays the groundwork for the macOS/iOS Swift client. Three real bugs found and + fixed (all were cross-platform issues that manifested on macOS but were latent on + Windows/Linux): + 1. **Missing `` in `test_m2_voice.cpp` POSIX branch** — the raw-socket test's + `#else` branch included ``/``/``/`` + but not `` (needed for `addrinfo`/`getaddrinfo`/`freeaddrinfo`). On Linux glibc + these headers transitively include ``; on macOS they don't. Fixed by adding + `# include ` to the POSIX branch (mirrors `core/src/core/client.cpp:16` which + already had it). Real latent bug — would fail on any strict POSIX system. + 2. **SIGPIPE killing processes on macOS** — on macOS, writing to a closed TCP socket + raises `SIGPIPE` by default (unlike Windows where it doesn't exist, or Linux where it's + often benign). This killed `test_tofu_flow` (intermittent SIGPIPE/SEGFAULT) and would + also kill `voicecat-server` and `vccli` in production when a peer dropped mid-write. + Fixed by ignoring SIGPIPE (`std::signal(SIGPIPE, SIG_IGN)`) in both the core client + init (`core/src/core/client.cpp` POSIX branch of the `#ifdef _WIN32` WSAStartup block) + and the server startup (`server/src/server.cpp` before the `asio::signal_set`). Both are + POSIX-only (`#ifndef _WIN32`), process-global, and idempotent. The server's + `asio::signal_set(SIGINT, SIGTERM)` is unaffected (independent signals). + 3. **Use-after-free of Asio's kqueue reactor on server shutdown** — the root cause of the + deterministic `test_tofu_flow` segfault (EXC_BAD_ACCESS in + `kqueue_reactor::deregister_descriptor(this=0x0000000000000000)`). `TcpServerConn`'s + `tls_read_loop` runs on a dedicated blocking-I/O thread (not async on `io_context`). + When `Server::stop()` → `io.stop()` → `Server::run()` returned, the local + `asio::io_context` was destroyed while `tls_read_loop` threads were still running. When + a thread detected the disconnect and called `TcpServerConn::close()` → `socket.close()` + → Asio tried to deregister from the kqueue reactor — but the reactor (owned by + `io_context`) was already destroyed, and on macOS kqueue the reactor pointer is null'd + immediately. Latent on Windows (IOCP) and Linux (epoll) where the timing is more + forgiving. **Fix:** `TcpAcceptor` now tracks its connections (new `conns_` vector + + `conns_mu_`); `TcpAcceptor::stop()` closes all tracked connections while `io_context` is + still alive; new `TcpAcceptor::shutdown()` method calls `stop()` then + `wait_closed()` on each connection (new `TcpServerConn::wait_closed()` joins + `tls_thread_`); `Server::run()` calls `acceptor.shutdown()` after `io.run()` returns and + before `io` is destroyed; `Server::stop()`'s `stop_fn_` now calls `acceptor.stop()` + + `media_relay->stop()` + `io.stop()` (was just `io.stop()`). After `acceptor.shutdown()`, + when `tls_read_loop` threads exit and call `on_disconnected` → `ConnSession::close()` → + `TcpServerConn::close()`, the `close()` is a no-op (`closing_.exchange(true)` returns + true) — no reactor access occurs after `io` is destroyed. + - **Environment setup:** vcpkg cloned to `~/code/vcpkg` + bootstrapped. `VCPKG_ROOT` must + be set. Homebrew `autoconf-archive` is required (vcpkg's libsodium port needs it for + autoreconf — `brew install autoconf-archive`). `autoconf`/`automake`/`libtool` were + already installed; `glibtoolize` (Homebrew's macOS name for `libtoolize`) is handled by + vcpkg automatically. + - **Verified:** `cmake --preset dev` + `cmake --build --preset dev` green (21 binaries). + `ctest --preset dev --parallel 1` — **21/21 green** (2 consecutive runs, 64s each). + `cmake --preset apple-dev` + `cmake --build --preset apple-dev` green → valid 1.9 MB + arm64 `libvoicecat.a` (167 exported C ABI symbols, correct visibility). + `xcodebuild -create-xcframework` → valid `VoiceCatCore.xcframework` (macOS-arm64 slice + with `voicecat.h` headers). Server runtime: `voicecat-server` starts, generates identity, + SQLite, Lobby, binds TCP+UDP, clean SIGINT shutdown. Two `vccli` text chat over TLS + (M1 exit criterion on Mac). `vccli --voice --input-mode vad` starts a MIC stream via + CoreAudio (M2 protocol-level on Mac — ear test pending). `vccli --list-devices` + enumerates 4 CoreAudio input + 3 output devices with correct defaults. + - **Apple framework linking:** NOT needed — modern macOS ld (Xcode 26.5) auto-discovers + CoreAudio/CoreFoundation frameworks in `/System/Library/Frameworks` without explicit + `-framework` flags. miniaudio's `MINIAUDIO_IMPLEMENTATION` compiles the CoreAudio calls + inline, and the linker resolves them automatically. No `if(APPLE)` CMake block was added. + - **Docs updated:** `docs/building.md` (`apple-dev` row + §6 updated from "scaffolding" to + "validated"), `clients/apple/README.md` (macOS slice build confirmed, iOS slices still + scaffolding), `PROGRESS.md` (this entry). + - **Still deferred (per scope):** macOS `SCREEN_AUDIO` loopback via ScreenCaptureKit (stub + returns `false` — per `docs/voice.md §9`); iOS cross-compile presets (`apple-ios`/ + `apple-ios-sim` — scaffolding); `vc_audio_suspend`/`vc_audio_resume` ABI hooks (defer to + iOS client milestone, keep ABI stable). + - **Done:** **CMake preset cleanup + cross-platform build config** (2026-06-18). The preset set was a mess — `dev` (never used), `m1-dev` (the one everyone used), `m2-dev` (cache-identical to `m1-dev`, never used), no optimized+tests preset, no stripping. diff --git a/clients/apple/README.md b/clients/apple/README.md index dc34784..b78e797 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -18,11 +18,22 @@ Planned shape (see [`docs/architecture.md`](../../docs/architecture.md) §4 and Nothing here yet — the core must reach M2 (working voice) before the GUI is worth building. -## Building the core for Apple platforms (scaffolding) +## Building the core for Apple platforms The CMake presets `apple-dev`, `apple-ios`, and `apple-ios-sim` produce static `libvoicecat.a` -slices for the Swift Package / XCFramework. They are **scaffolding** — not yet CI-validated. -Build on macOS (they won't work on Windows/Linux): +slices for the Swift Package / XCFramework. + +**`apple-dev` (macOS slice) is validated** — builds green on macOS 26.5 / Apple Silicon +(Apple clang 21, vcpkg `arm64-osx` triplet) and produces a valid 1.9 MB arm64 static library +with 167 exported C ABI symbols (correct visibility). The XCFramework creation path is also +verified — `xcodebuild -create-xcframework` produces a valid `VoiceCatCore.xcframework` +with the `.a` + `voicecat.h` headers, ready for a Swift Package binary target. + +**`apple-ios` and `apple-ios-sim` (iOS slices) are scaffolding** — not yet CI-validated. +The vcpkg `arm64-ios` / `arm64-ios-sim` triplets for all 8 deps need verification. + +Prerequisites: `VCPKG_ROOT` set, Xcode + iOS SDK installed, and Homebrew `autoconf-archive` +(needed by vcpkg's libsodium port — `brew install autoconf-archive`). ```bash # Prerequisites: VCPKG_ROOT set, Xcode + iOS SDK installed @@ -51,6 +62,8 @@ xcodebuild -create-xcframework \ -output build/VoiceCatCore.xcframework ``` -The XCFramework is then consumed by the Swift Package as a binary target. Actual +The XCFramework is then consumed by the Swift Package as a binary target. The macOS-only +XCFramework (single `apple-dev` slice) is verified to build now; the full 3-slice XCFramework +(macOS + iOS device + iOS simulator) waits for the iOS presets to be validated. Actual `AVAudioSession` integration, `Info.plist` mic permission, ReplayKit extension, and SwiftUI -UI work are tracked as follow-up tasks — the presets exist so the build entry point is ready. +UI work are tracked as follow-up tasks. diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index fa01073..746e2a4 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include @@ -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{}; diff --git a/core/src/net/transport.cpp b/core/src/net/transport.cpp index 5709103..79b6790 100644 --- a/core/src/net/transport.cpp +++ b/core/src/net/transport.cpp @@ -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> 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> 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(); }); } diff --git a/core/src/net/transport.h b/core/src/net/transport.h index 805bc7d..b8185da 100644 --- a/core/src/net/transport.h +++ b/core/src/net/transport.h @@ -112,6 +112,10 @@ class TcpServerConn : public std::enable_shared_from_this { // 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(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> conns_; }; // ── UDP media channel (M2) ─────────────────────────────────────────────────── diff --git a/docs/building.md b/docs/building.md index 94fe510..9939add 100644 --- a/docs/building.md +++ b/docs/building.md @@ -17,7 +17,7 @@ and *how to drive the binaries by hand*. | `release` | `build/release` | vcpkg | Release | ON | ON | ON | no | all | Optimized build with the full test suite. Use to run tests against optimized code, profile, or catch optimizer-sensitive bugs. Symbols kept (not stripped) so stack traces and profiling remain useful. | | `server-release` | `build/server-release` | vcpkg | Release | ON | ON | OFF | **yes** | all | Production-shaped build for deployment. Optimized + stripped binaries (`-s`), no tests. This is what you'd ship/run — see [docs/deployment.md](deployment.md). | | `windows-client` | `build/windows-client` | vcpkg | Release | OFF | OFF | OFF | no | Windows | Produces a redistributable `voicecat.dll` for the C# WinForms client (M4). Static MinGW runtime — no `libgcc_s_seh-1.dll` etc. See [clients/windows/README.md](../clients/windows/README.md). | -| `apple-dev` | `build/apple-dev` | vcpkg | Release | OFF | OFF | OFF | no | macOS | **Scaffolding** — static `libvoicecat.a` for the Swift Package / XCFramework (macOS slice). Not yet CI-validated; build on macOS to verify. See [clients/apple/README.md](../clients/apple/README.md). | +| `apple-dev` | `build/apple-dev` | vcpkg | Release | OFF | OFF | OFF | no | macOS | Static `libvoicecat.a` for the Swift Package / XCFramework (macOS slice). Validated on macOS 26.5 / Apple Silicon — builds green, produces valid arm64 `.a` + XCFramework. See [clients/apple/README.md](../clients/apple/README.md). | | `apple-ios` | `build/apple-ios` | vcpkg | Release | OFF | OFF | OFF | no | macOS→iOS | **Scaffolding** — cross-compiled static `libvoicecat.a` for iOS device (`arm64-ios`). One XCFramework slice. Not yet CI-validated. | | `apple-ios-sim` | `build/apple-ios-sim` | vcpkg | Release | OFF | OFF | OFF | no | macOS→iOS sim | **Scaffolding** — cross-compiled static `libvoicecat.a` for iOS simulator (`arm64-ios-sim`). One XCFramework slice. Not yet CI-validated. | @@ -207,8 +207,10 @@ symbol tables from the binaries, producing smaller executables suitable for dist ## 6. Apple platform builds (scaffolding) The `apple-dev`, `apple-ios`, and `apple-ios-sim` presets produce static `libvoicecat.a` -slices for the Swift Package / XCFramework. They are **scaffolding** — not yet CI-validated -and won't build on Windows. Build on macOS: +slices for the Swift Package / XCFramework. The `apple-dev` preset (macOS slice) is +**validated** — it builds green on macOS 26.5 / Apple Silicon and produces a valid arm64 +`.a` + XCFramework. The iOS cross-compile presets (`apple-ios`, `apple-ios-sim`) are still +**scaffolding** — not yet CI-validated. Build on macOS: ```bash # macOS slice (arm64-osx on Apple Silicon, x64-osx on Intel) diff --git a/server/src/server.cpp b/server/src/server.cpp index d433a73..17664a4 100644 --- a/server/src/server.cpp +++ b/server/src/server.cpp @@ -1,6 +1,7 @@ #include "server.h" #include +#include #ifdef VOICECAT_HAS_NET @@ -135,13 +136,26 @@ int Server::run() { // Arm programmatic stop (for tests and embedders). { std::lock_guard lk(stop_mutex_); - stop_fn_ = [&io] { io.stop(); }; + stop_fn_ = [&] { + acceptor.stop(); + media_relay->stop(); + io.stop(); + }; } // Notify caller of the actual bound port (matters when bind_port==0). uint16_t bound = acceptor.local_port(); if (cfg_.on_ready) cfg_.on_ready(bound); +#ifndef _WIN32 + // POSIX/macOS: ignore SIGPIPE — writing to a disconnected client's TCP socket returns + // EPIPE instead of killing the server process. On macOS SIGPIPE is delivered by + // default (unlike Windows where the signal doesn't exist). Must be set before any + // async writes are posted. Process-global; safe alongside the asio::signal_set below + // (which only catches SIGINT/SIGTERM). + std::signal(SIGPIPE, SIG_IGN); +#endif + // Graceful shutdown on SIGINT/SIGTERM asio::signal_set signals(io, SIGINT, SIGTERM); signals.async_wait([&](std::error_code, int sig) { @@ -184,6 +198,13 @@ int Server::run() { io.run(); + // Close all connections and block until their TLS I/O threads have finished. + // MUST happen before io (and its reactor) is destroyed — the TLS read threads do + // blocking I/O (not async on io_context) and touch the reactor on socket close. + // On macOS kqueue the reactor pointer is null'd immediately on io_context destruction, + // causing a segfault; latent on Windows IOCP / Linux epoll where timing is more forgiving. + acceptor.shutdown(); + { std::lock_guard lk(stop_mutex_); stop_fn_ = nullptr; diff --git a/tests/test_m2_voice.cpp b/tests/test_m2_voice.cpp index 35e8ccf..03dcc0d 100644 --- a/tests/test_m2_voice.cpp +++ b/tests/test_m2_voice.cpp @@ -36,6 +36,7 @@ static int sock_error() { return WSAGetLastError(); } #else # include +# include # include # include # include