Files
voice-cat/server/src/session_registry.h

177 lines
6.5 KiB
C
Raw Normal View History

2026-07-23 13:37:05 +02:00
/* Thread-safe in-memory session, channel, user, and media registry. */
2026-06-15 23:48:44 +02:00
#ifndef VOICECAT_SERVER_SESSION_REGISTRY_H
#define VOICECAT_SERVER_SESSION_REGISTRY_H
#include <array>
#include <atomic>
2026-06-15 23:48:44 +02:00
#include <cstdint>
#include <memory>
#include <optional>
2026-06-15 23:48:44 +02:00
#include <shared_mutex>
#include <string>
#include <unordered_map>
#include <vector>
#define ASIO_STANDALONE 1
#include <asio.hpp>
#include "db.h"
2026-06-15 23:48:44 +02:00
#include "proto/voicecat.pb.h"
namespace voicecat::server {
class ConnSession;
struct ChannelEntry {
voicecat::v1::Channel proto;
};
struct UserEntry {
voicecat::v1::User proto;
uint64_t session_id{};
};
// Hashes asio::ip::udp::endpoint by "addr:port" string.
struct UdpEndpointHash {
size_t operator()(const asio::ip::udp::endpoint& ep) const {
std::string key = ep.address().to_string() + ':' + std::to_string(ep.port());
return std::hash<std::string>{}(key);
}
};
2026-06-15 23:48:44 +02:00
class SessionRegistry {
public:
explicit SessionRegistry(std::shared_ptr<Database> db);
2026-06-15 23:48:44 +02:00
void load_channels();
2026-06-15 23:48:44 +02:00
uint64_t register_session(std::weak_ptr<ConnSession> session);
void unregister_session(uint64_t session_id);
uint32_t add_user(uint64_t session_id, const voicecat::v1::User& user);
void remove_user(uint32_t user_id);
fix(net): broadcast LEFT on disconnect, add keepalive/reaper, cap PLC hiss Three reported bugs traced to one root cause plus two missing designed features: 1. Stale users + eternal PLC hiss (root cause): ConnSession::close() silently erased dropped users without broadcasting UserEvent::LEFT, so peers never learned the user left and their audio engines never called remove_stream — Opus PLC synthesized comfort noise forever. Fix: broadcast_left() helper + close() broadcasts LEFT before erasing. 2. PLC cap (defense-in-depth): on_playback now caps pure PLC at ~2s, then emits digital silence so a stale stream can never hiss forever even if remove_stream is skipped. Resets automatically on fresh packets. 3. No timeout / no ping: client never sent Ping, server had no last_seen / reaper, so half-open connections (NAT timeout, wifi loss, sleep) left ghost users forever. Fix: client Ping every 15s with RTT measurement, ConnSession::last_seen bumped on every inbound TCP/UDP frame, steady_timer reaper sweeps every 15s and drops sessions older than 45s (configurable via server::Config). 4. UDP KEEPALIVE: client sends plaintext kFrameKeepalive every 5s; server bumps last_seen + echoes back. Keeps NAT bindings alive and lets media activity defer the reaper independently of TCP. 5. Graceful client disconnect: vc_disconnect() sends Disconnect{code=0} via a flag-based io-thread exit (no double-close race); server handles client-sent Disconnect with immediate close() + LEFT broadcast. 3 new tests: disconnect_left, plc_cap, reaper_timeout. 21/21 ctest green. Docs: protocol.md §6/§7, voice.md §6, architecture.md §5, PROGRESS.md.
2026-06-18 01:18:33 +02:00
// Broadcast a UserEvent::LEFT for a user to all other sessions. Called by
// ConnSession::close() before remove_user() so remaining clients learn about an
// ungraceful disconnect (TCP drop, crash, network loss). Mirrors the first half of
// kick_user(). Takes the shared lock internally; safe to call from ConnSession::close.
void broadcast_left(uint32_t user_id, const std::string& reason);
2026-06-15 23:48:44 +02:00
bool set_user_channel(uint32_t user_id, uint32_t channel_id);
feat: fix voice join/leave, channel edit defaults, channel-update stream restart Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment).
2026-06-24 14:29:39 +02:00
void set_user_voice_subscribed(uint32_t user_id, bool subscribed);
2026-06-15 23:48:44 +02:00
std::vector<voicecat::v1::Channel> channel_snapshot() const;
std::vector<voicecat::v1::User> user_snapshot() const;
std::optional<voicecat::v1::User> user_snapshot_user(uint32_t user_id) const;
std::optional<std::string> user_nickname(uint32_t user_id) const;
2026-06-15 23:48:44 +02:00
std::vector<std::shared_ptr<ConnSession>> resolve_text_targets(
uint64_t sender_session_id, voicecat::v1::TextScope scope, uint32_t target_id) const;
void broadcast(const voicecat::v1::Envelope& env, uint64_t exclude_session_id = 0) const;
2026-07-23 13:37:05 +02:00
// The caller closes returned sessions outside the registry lock because close re-enters it.
fix(net): broadcast LEFT on disconnect, add keepalive/reaper, cap PLC hiss Three reported bugs traced to one root cause plus two missing designed features: 1. Stale users + eternal PLC hiss (root cause): ConnSession::close() silently erased dropped users without broadcasting UserEvent::LEFT, so peers never learned the user left and their audio engines never called remove_stream — Opus PLC synthesized comfort noise forever. Fix: broadcast_left() helper + close() broadcasts LEFT before erasing. 2. PLC cap (defense-in-depth): on_playback now caps pure PLC at ~2s, then emits digital silence so a stale stream can never hiss forever even if remove_stream is skipped. Resets automatically on fresh packets. 3. No timeout / no ping: client never sent Ping, server had no last_seen / reaper, so half-open connections (NAT timeout, wifi loss, sleep) left ghost users forever. Fix: client Ping every 15s with RTT measurement, ConnSession::last_seen bumped on every inbound TCP/UDP frame, steady_timer reaper sweeps every 15s and drops sessions older than 45s (configurable via server::Config). 4. UDP KEEPALIVE: client sends plaintext kFrameKeepalive every 5s; server bumps last_seen + echoes back. Keeps NAT bindings alive and lets media activity defer the reaper independently of TCP. 5. Graceful client disconnect: vc_disconnect() sends Disconnect{code=0} via a flag-based io-thread exit (no double-close race); server handles client-sent Disconnect with immediate close() + LEFT broadcast. 3 new tests: disconnect_left, plc_cap, reaper_timeout. 21/21 ctest green. Docs: protocol.md §6/§7, voice.md §6, architecture.md §5, PROGRESS.md.
2026-06-18 01:18:33 +02:00
std::vector<std::shared_ptr<ConnSession>> find_stale_sessions(int64_t max_age_ms) const;
private:
void broadcast_unlocked(const voicecat::v1::Envelope& env,
uint64_t exclude_session_id = 0) const;
public:
void set_session_permissions(uint64_t session_id,
const voicecat::v1::Permissions& perms);
std::optional<voicecat::v1::Permissions> get_session_permissions(
uint64_t session_id) const;
std::shared_ptr<ConnSession> find_session_by_user_id(uint32_t user_id) const;
bool kick_user(uint32_t user_id, const std::string& reason);
bool ban_user(uint32_t user_id, const std::string& reason, int64_t expires_at);
bool set_server_mute(uint32_t user_id, bool muted, bool deafened);
bool move_user(uint32_t user_id, uint32_t channel_id);
uint32_t create_channel(const voicecat::v1::Channel& ch, const std::string& password,
std::string& error);
bool update_channel(const voicecat::v1::Channel& ch, const std::string& password,
std::string& error);
2026-07-23 13:37:05 +02:00
// Deleting a channel moves its users to Lobby.
bool delete_channel(uint32_t channel_id, std::string& error);
std::optional<voicecat::v1::Channel> get_channel(uint32_t channel_id) const;
bool check_channel_password(uint32_t channel_id, const std::string& password) const;
void register_udp_token(const std::array<uint8_t, 16>& token, uint64_t session_id);
std::shared_ptr<ConnSession> find_by_udp_token(const std::array<uint8_t, 16>& token) const;
void register_udp_endpoint(asio::ip::udp::endpoint ep, uint64_t session_id);
std::shared_ptr<ConnSession> find_by_udp_endpoint(const asio::ip::udp::endpoint& ep) const;
uint32_t assign_ssrc(uint64_t session_id);
// Add/replace a stream entry on a user (called when StreamAnnounce succeeds).
// Returns the updated User proto for broadcasting, or nullopt if user not found.
std::optional<voicecat::v1::User> set_user_stream(uint32_t user_id,
const voicecat::v1::StreamInfo& info);
// Remove a stream entry from a user (called on StreamStop). Returns the updated
// User proto for broadcasting, or nullopt if user not found.
std::optional<voicecat::v1::User> clear_user_stream(uint32_t user_id, uint32_t stream_id);
std::vector<std::shared_ptr<ConnSession>> find_channel_sessions(
uint32_t channel_id, uint64_t exclude_session_id = 0) const;
uint32_t user_channel(uint32_t user_id) const;
2026-07-23 13:37:05 +02:00
// Returns the authoritative per-channel Opus configuration.
feat(M3): multi-stream & per-channel tuning Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC + SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise- reduction, talk indicators, and enforced per-channel Opus configurability (mono/stereo, bitrate, frame size, FEC/DTX, application). Bugs fixed along the way (found while implementing, not pre-existing scope): - Server hard-coded stream_id=1 for every announce, so a second stream from the same user silently overwrote the first in SessionRegistry::set_user_stream. Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop validates against announced_stream_ids_ before clearing. - Client dropped mode/dtx/complexity/application from effective_audio even for the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever applied to OpusParams. Fixed on both the send (handle_stream_announce_result) and receive (sync_remote_streams) paths via a shared opus_params_from_audio_config() helper. - OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application and wired it through. - on_playback's per-stream decode passed the wrong frame_size to opus_decode (total samples instead of samples-per-channel), which would have overflowed the decode buffer for any stereo stream. - teardown_voice() raced when called concurrently from run_io()'s own cleanup and from disconnect() on a different thread -- both could see udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the same std::thread (intermittent std::system_error under ctest). Fixed with a teardown_mu_ guard instead of carrying the flake forward. New: - Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/ FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX); handle_stream_announce enforces the channel's config, clamping (not overriding) bitrate_bps to its ceiling. - core/src/core/client.h/.cpp: local-stream state is now a std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with request_id-correlated announce/result handling (request_id already round-tripped on the wire; just wasn't read before). on_capture_frame is kind-aware and upmixes mono capture to stereo when a stream's config calls for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired through set_remote_stream. New run_talk_timer() thread emits VC_EVENT_TALK_STATE from both remote and local edge detection. - core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps (inject_capture), stereo-to-mono downmix at the decode/mix boundary, RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled and last_voice_ms/talking; new set_stream_noise_reduction() and poll_talk_transitions(). - core/src/session/session.h/.cpp: Stream now carries the full AudioConfig, not just sample_rate/frame_ms. - New additive C ABI (core/include/voicecat.h): vc_audio_config + vc_get_stream_audio_config (effective Opus config for any stream you own or a peer's); vc_test_inject_capture (test-only synthetic PCM injection, clearly marked, mirrors AudioEngine::inject_capture). - tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI (mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two concurrent local streams, independent gain/mute/NS control, per-channel config divergence via vc_get_stream_audio_config, talk indicators. Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback capture for SCREEN_AUDIO (synthetic injection only); true stereo playback output (AudioEngine's mixer/output device stays mono -- Opus itself is fully stereo-correct on the wire). ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive full-suite runs plus 8 standalone runs of the new test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
std::optional<voicecat::v1::AudioConfig> channel_audio_config(uint32_t channel_id) const;
2026-06-15 23:48:44 +02:00
private:
void seed_default_channels();
2026-06-15 23:48:44 +02:00
mutable std::shared_mutex mu_;
std::shared_ptr<Database> db_;
2026-06-15 23:48:44 +02:00
uint64_t next_session_id_{1};
uint32_t next_user_id_{1};
uint32_t next_channel_id_{3}; // 1 and 2 are reserved for Lobby, Music Room
2026-06-15 23:48:44 +02:00
std::unordered_map<uint64_t, std::weak_ptr<ConnSession>> sessions_;
std::unordered_map<uint32_t, UserEntry> users_;
std::unordered_map<uint32_t, ChannelEntry> channels_;
std::unordered_map<uint64_t, voicecat::v1::Permissions> session_permissions_;
// Token → session_id (populated at auth, cleared on disconnect)
struct TokenHash {
size_t operator()(const std::array<uint8_t, 16>& t) const {
// FNV-1a over 16 bytes
size_t h = 14695981039346656037ULL;
for (auto b : t) { h ^= b; h *= 1099511628211ULL; }
return h;
}
};
std::unordered_map<std::array<uint8_t, 16>, uint64_t, TokenHash> udp_tokens_;
// UDP endpoint → session_id (populated after UDP binding packet arrives)
std::unordered_map<asio::ip::udp::endpoint, uint64_t, UdpEndpointHash> udp_endpoints_;
// ssrc → session_id (populated when StreamAnnounce is processed)
std::unordered_map<uint32_t, uint64_t> ssrc_to_session_;
std::atomic<uint32_t> next_ssrc_{1};
2026-06-15 23:48:44 +02:00
};
} // namespace voicecat::server
#endif // VOICECAT_SERVER_SESSION_REGISTRY_H