mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix: voice rejoin failure, SDP race, deafen bypass + add server voice logging
- Fix SDP signaling race condition: add per-client negoMu to serialize renegotiateParticipant / handleVoiceOffer / handleVoiceAnswer so concurrent OnTrack goroutines don't race through rollback - Fix handleVoiceLeave triple-fire: early return when clearVoice() returns zeros so ICE callbacks don't re-enter and corrupt state - Fix SQLite SQLITE_BUSY errors: add busy_timeout=5000 pragma and SetMaxOpenConns(1) for file-based databases - Fix deafen bypass: new remote audio elements now respect localDeafened state so late-arriving streams are muted immediately - Add debug-level logging for SDP negotiation, track fan-out, ICE candidates, voice state changes, room lifecycle, and participant add/remove - Bump version to 1.1.1
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owncord-client"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
edition = "2021"
|
||||
description = "OwnCord Desktop Client"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "OwnCord",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"identifier": "com.owncord.client",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -219,6 +219,12 @@ function addRemoteStream(stream: MediaStream): void {
|
||||
const savedVolume = userId > 0 ? getSavedUserVolume(userId) : 100;
|
||||
audio.volume = Math.min(savedVolume, 100) / 100;
|
||||
|
||||
// Respect current deafen state — if user is deafened, mute this element
|
||||
// immediately so late-arriving streams don't bypass the deafen.
|
||||
if (voiceStore.getState().localDeafened) {
|
||||
audio.muted = true;
|
||||
}
|
||||
|
||||
if (userId > 0) {
|
||||
userAudioElements.set(userId, audio);
|
||||
}
|
||||
|
||||
+11
-5
@@ -29,11 +29,11 @@ func Open(path string) (*DB, error) {
|
||||
return nil, fmt.Errorf("pinging sqlite db: %w", err)
|
||||
}
|
||||
|
||||
// In-memory databases are per-connection in SQLite; pin to one connection
|
||||
// so all callers share the same in-memory state.
|
||||
if path == ":memory:" {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
// SQLite only allows one writer at a time. Pin to a single connection
|
||||
// so concurrent goroutines queue on the Go side rather than getting
|
||||
// SQLITE_BUSY. For :memory: databases this also ensures all callers
|
||||
// share the same in-memory state.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
// Enable WAL mode for better concurrent read performance.
|
||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL;"); err != nil {
|
||||
@@ -41,6 +41,12 @@ func Open(path string) (*DB, error) {
|
||||
return nil, fmt.Errorf("enabling WAL mode: %w", err)
|
||||
}
|
||||
|
||||
// Wait up to 5 seconds for the write lock instead of failing instantly.
|
||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000;"); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("setting busy_timeout: %w", err)
|
||||
}
|
||||
|
||||
// Enforce foreign key constraints.
|
||||
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON;"); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
@@ -33,6 +33,7 @@ type Client struct {
|
||||
send chan []byte
|
||||
mu sync.Mutex // guards sendClosed, msgCount, channelID
|
||||
voiceMu sync.Mutex // guards voiceChID and pc
|
||||
negoMu sync.Mutex // serialises SDP signalling (renegotiate / handleOffer / handleAnswer) per client
|
||||
}
|
||||
|
||||
// wsConn is the subset of nhooyr.io/websocket.Conn used by writePump/readPump.
|
||||
|
||||
@@ -130,6 +130,7 @@ func (h *Hub) RemoveVoiceRoom(channelID int64) {
|
||||
h.voiceRoomsMu.Unlock()
|
||||
|
||||
if ok {
|
||||
slog.Info("voice room destroyed", "channel_id", channelID)
|
||||
room.Close()
|
||||
}
|
||||
}
|
||||
|
||||
+59
-11
@@ -97,6 +97,11 @@ func (h *Hub) setupICECallback(c *Client, channelID int64) {
|
||||
// and sends it as voice_offer. Implements the "impolite" side of
|
||||
// Perfect Negotiation — skips if PC is in have-remote-offer state.
|
||||
func (h *Hub) renegotiateParticipant(c *Client) {
|
||||
// Serialise all SDP signalling for this client so concurrent OnTrack
|
||||
// goroutines don't race through state-check → rollback → createOffer.
|
||||
c.negoMu.Lock()
|
||||
defer c.negoMu.Unlock()
|
||||
|
||||
pc := c.getPC()
|
||||
if pc == nil {
|
||||
return
|
||||
@@ -106,6 +111,8 @@ func (h *Hub) renegotiateParticipant(c *Client) {
|
||||
// mid-negotiation (client sent us an offer, or we sent one and are
|
||||
// waiting for an answer).
|
||||
state := pc.SignalingState()
|
||||
slog.Debug("renegotiateParticipant enter",
|
||||
"user_id", c.userID, "signaling_state", state.String())
|
||||
if state == webrtc.SignalingStateHaveRemoteOffer {
|
||||
slog.Info("renegotiate skipped: have-remote-offer",
|
||||
"user_id", c.userID)
|
||||
@@ -121,6 +128,8 @@ func (h *Hub) renegotiateParticipant(c *Client) {
|
||||
"err", err, "user_id", c.userID)
|
||||
return
|
||||
}
|
||||
slog.Debug("renegotiateParticipant rollback OK",
|
||||
"user_id", c.userID)
|
||||
}
|
||||
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
@@ -137,6 +146,9 @@ func (h *Hub) renegotiateParticipant(c *Client) {
|
||||
}
|
||||
|
||||
channelID := c.getVoiceChID()
|
||||
slog.Debug("renegotiateParticipant offer sent",
|
||||
"user_id", c.userID, "channel_id", channelID,
|
||||
"signaling_state", pc.SignalingState().String())
|
||||
c.sendMsg(buildVoiceOffer(channelID, offer.SDP))
|
||||
}
|
||||
|
||||
@@ -303,13 +315,15 @@ func (h *Hub) buildVoiceRoomConfig(ch *db.Channel) VoiceRoomConfig {
|
||||
// 4. Removes voice state from DB.
|
||||
// 5. Broadcasts voice_leave to the channel the user was in.
|
||||
func (h *Hub) handleVoiceLeave(c *Client) {
|
||||
state, err := h.db.GetVoiceState(c.userID)
|
||||
if err != nil {
|
||||
slog.Error("ws handleVoiceLeave GetVoiceState", "err", err, "user_id", c.userID)
|
||||
}
|
||||
|
||||
// Atomically clear voice state and get old values for cleanup (CRIT-1 fix).
|
||||
// Only the first caller gets real values; concurrent calls (e.g. ICE
|
||||
// callbacks racing with an explicit voice_leave) get oldChID=0 and
|
||||
// become no-ops.
|
||||
oldChID, oldPC := c.clearVoice()
|
||||
if oldChID == 0 && oldPC == nil {
|
||||
slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID)
|
||||
return
|
||||
}
|
||||
|
||||
// Close PeerConnection if active.
|
||||
// This also causes any setupOnTrack goroutine to exit via track.Read error (HIGH-1).
|
||||
@@ -342,6 +356,9 @@ func (h *Hub) handleVoiceLeave(c *Client) {
|
||||
if rmErr := subPC.RemoveTrack(sender); rmErr != nil {
|
||||
slog.Error("handleVoiceLeave RemoveTrack",
|
||||
"err", rmErr, "user_id", subID, "kind", kind)
|
||||
} else {
|
||||
slog.Debug("handleVoiceLeave track removed from subscriber",
|
||||
"leaving_user", c.userID, "subscriber", subID, "kind", kind)
|
||||
}
|
||||
needsRenego[subID] = sub
|
||||
}
|
||||
@@ -362,12 +379,13 @@ func (h *Hub) handleVoiceLeave(c *Client) {
|
||||
}
|
||||
}
|
||||
|
||||
if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil {
|
||||
slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID)
|
||||
}
|
||||
slog.Info("voice leave", "user_id", c.userID, "channel_id", oldChID)
|
||||
|
||||
if state != nil {
|
||||
h.BroadcastToAll(buildVoiceLeave(state.ChannelID, c.userID))
|
||||
if oldChID > 0 {
|
||||
if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil {
|
||||
slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID)
|
||||
}
|
||||
h.BroadcastToAll(buildVoiceLeave(oldChID, c.userID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +407,7 @@ func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) {
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to update mute state"))
|
||||
return
|
||||
}
|
||||
slog.Debug("voice mute changed", "user_id", c.userID, "muted", p.Muted)
|
||||
|
||||
h.broadcastVoiceStateUpdate(c)
|
||||
}
|
||||
@@ -411,6 +430,7 @@ func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) {
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to update deafen state"))
|
||||
return
|
||||
}
|
||||
slog.Debug("voice deafen changed", "user_id", c.userID, "deafened", p.Deafened)
|
||||
|
||||
h.broadcastVoiceStateUpdate(c)
|
||||
}
|
||||
@@ -473,6 +493,7 @@ func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to update camera state"))
|
||||
return
|
||||
}
|
||||
slog.Debug("voice camera changed", "user_id", c.userID, "enabled", p.Enabled)
|
||||
|
||||
h.broadcastVoiceStateUpdate(c)
|
||||
}
|
||||
@@ -513,6 +534,7 @@ func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) {
|
||||
c.sendMsg(buildErrorMsg("INTERNAL", "failed to update screenshare state"))
|
||||
return
|
||||
}
|
||||
slog.Debug("voice screenshare changed", "user_id", c.userID, "enabled", p.Enabled)
|
||||
|
||||
h.broadcastVoiceStateUpdate(c)
|
||||
}
|
||||
@@ -551,10 +573,19 @@ func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) {
|
||||
SDP: p.SDP,
|
||||
}
|
||||
|
||||
// Serialise SDP signalling so a concurrent renegotiateParticipant
|
||||
// cannot race with this offer/answer exchange.
|
||||
c.negoMu.Lock()
|
||||
defer c.negoMu.Unlock()
|
||||
|
||||
stateBefore := pc.SignalingState()
|
||||
slog.Debug("handleVoiceOffer enter",
|
||||
"user_id", c.userID, "signaling_state", stateBefore.String())
|
||||
|
||||
// Perfect Negotiation: if we already have a pending local offer (glare
|
||||
// condition — server and client sent offers simultaneously), roll back
|
||||
// ours so we can accept the client's offer.
|
||||
if pc.SignalingState() == webrtc.SignalingStateHaveLocalOffer {
|
||||
if stateBefore == webrtc.SignalingStateHaveLocalOffer {
|
||||
if err := pc.SetLocalDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeRollback,
|
||||
}); err != nil {
|
||||
@@ -584,6 +615,9 @@ func (h *Hub) handleVoiceOffer(c *Client, payload json.RawMessage) {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("handleVoiceOffer answer sent",
|
||||
"user_id", c.userID,
|
||||
"signaling_state", pc.SignalingState().String())
|
||||
// Send the answer back to the client.
|
||||
c.sendMsg(buildVoiceAnswer(c.getVoiceChID(), answer.SDP))
|
||||
}
|
||||
@@ -622,11 +656,22 @@ func (h *Hub) handleVoiceAnswer(c *Client, payload json.RawMessage) {
|
||||
SDP: p.SDP,
|
||||
}
|
||||
|
||||
// Serialise with renegotiateParticipant — SetRemoteDescription(answer)
|
||||
// transitions from have-local-offer → stable and must not race with a
|
||||
// concurrent rollback + new offer.
|
||||
c.negoMu.Lock()
|
||||
defer c.negoMu.Unlock()
|
||||
|
||||
stateBefore := pc.SignalingState()
|
||||
if err := pc.SetRemoteDescription(answer); err != nil {
|
||||
slog.Error("ws handleVoiceAnswer SetRemoteDescription", "err", err, "user_id", c.userID)
|
||||
c.sendMsg(buildErrorMsg("INVALID_SDP", "failed to set remote description"))
|
||||
return
|
||||
}
|
||||
slog.Debug("handleVoiceAnswer applied",
|
||||
"user_id", c.userID,
|
||||
"state_before", stateBefore.String(),
|
||||
"state_after", pc.SignalingState().String())
|
||||
}
|
||||
|
||||
// handleVoiceICE processes a voice_ice (ICE candidate) from the client.
|
||||
@@ -662,6 +707,7 @@ func (h *Hub) handleVoiceICE(c *Client, payload json.RawMessage) {
|
||||
c.sendMsg(buildErrorMsg("VOICE_ERROR", "failed to add ICE candidate"))
|
||||
return
|
||||
}
|
||||
slog.Debug("client ICE candidate added", "user_id", c.userID)
|
||||
}
|
||||
|
||||
// handleSoundboard processes a soundboard_play message.
|
||||
@@ -775,6 +821,8 @@ func (h *Hub) setupOnTrack(c *Client, channelID int64) {
|
||||
vt.AddSender(pid, sender)
|
||||
}
|
||||
addedCount++
|
||||
slog.Debug("setupOnTrack track added to subscriber",
|
||||
"from", c.userID, "to", pid, "kind", kind)
|
||||
h.renegotiateParticipant(other)
|
||||
}
|
||||
slog.Info("SFU track fan-out",
|
||||
|
||||
@@ -88,6 +88,12 @@ func NewVoiceRoom(cfg VoiceRoomConfig) *VoiceRoom {
|
||||
if topN <= 0 {
|
||||
topN = 3
|
||||
}
|
||||
slog.Info("voice room created",
|
||||
"channel_id", cfg.ChannelID,
|
||||
"max_users", cfg.MaxUsers,
|
||||
"quality", cfg.Quality,
|
||||
"mixing_threshold", cfg.MixingThreshold,
|
||||
"max_video", cfg.MaxVideo)
|
||||
return &VoiceRoom{
|
||||
config: cfg,
|
||||
participants: make(map[int64]*VoiceParticipant),
|
||||
@@ -106,6 +112,8 @@ func (r *VoiceRoom) AddParticipant(userID int64) error {
|
||||
|
||||
// Duplicate check — already present, nothing to do.
|
||||
if _, exists := r.participants[userID]; exists {
|
||||
slog.Debug("voice room participant already present",
|
||||
"channel_id", r.config.ChannelID, "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -118,6 +126,11 @@ func (r *VoiceRoom) AddParticipant(userID int64) error {
|
||||
JoinedAt: time.Now(),
|
||||
}
|
||||
|
||||
slog.Info("voice room participant added",
|
||||
"channel_id", r.config.ChannelID,
|
||||
"user_id", userID,
|
||||
"participants", len(r.participants))
|
||||
|
||||
r.updateMode()
|
||||
return nil
|
||||
}
|
||||
@@ -134,6 +147,10 @@ func (r *VoiceRoom) RemoveParticipant(userID int64) {
|
||||
|
||||
delete(r.participants, userID)
|
||||
r.detector.RemoveSpeaker(userID)
|
||||
slog.Info("voice room participant removed",
|
||||
"channel_id", r.config.ChannelID,
|
||||
"user_id", userID,
|
||||
"participants", len(r.participants))
|
||||
r.updateMode()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user