fix: voice session cleanup on ICE close and connection failure

- Server: handle ICEConnectionStateClosed in setupICEMonitor to clean up
  phantom participants when client PC is destroyed
- Server: skip TURN config when turn_secret is empty (suppresses noisy
  "password is empty" errors)
- Client: voiceSession.leaveVoice() now sends voice_leave to server by
  default, fixing the case where WebRTC failure triggers local cleanup
  but server never learns the user left
- Client: explicit leave paths (UI button, logout, beforeunload) pass
  sendWs=false to avoid double-sending voice_leave
This commit is contained in:
jevb
2026-03-18 04:13:12 +01:00
parent b067c71c13
commit 66abbed49d
5 changed files with 22 additions and 8 deletions
+11 -2
View File
@@ -220,8 +220,17 @@ export async function joinVoice(
}
}
/** Leave the current voice session and clean up all resources. */
export function leaveVoice(): void {
/**
* Leave the current voice session and clean up all resources.
* If sendWs is true (default), also notifies the server via voice_leave.
* Pass sendWs=false when the server already knows (e.g. explicit UI leave
* that sends voice_leave separately).
*/
export function leaveVoice(sendWs = true): void {
// Notify server so it cleans up our voice state
if (sendWs && ws !== null) {
ws.send({ type: "voice_leave", payload: {} });
}
// Stop all local media tracks
if (localStream !== null) {
for (const track of localStream.getTracks()) {
+2 -2
View File
@@ -293,7 +293,7 @@ authStore.subscribe((state) => {
// Leave voice channel before disconnecting so other clients see it immediately
const voice = voiceStore.getState();
if (voice.currentChannelId !== null) {
voiceSessionLeave();
voiceSessionLeave(false); // false: we send voice_leave below
ws.send({ type: "voice_leave", payload: {} });
leaveVoiceChannel();
}
@@ -313,7 +313,7 @@ authStore.subscribe((state) => {
window.addEventListener("beforeunload", () => {
const voice = voiceStore.getState();
if (voice.currentChannelId !== null) {
voiceSessionLeave();
voiceSessionLeave(false); // false: we send voice_leave below
ws.send({ type: "voice_leave", payload: {} });
}
});
+2 -2
View File
@@ -375,7 +375,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
},
onVoiceLeave: () => {
log.info("Leaving voice channel");
voiceSessionLeave();
voiceSessionLeave(false); // false: we send voice_leave below
leaveVoiceChannel();
ws.send({ type: "voice_leave", payload: {} });
},
@@ -415,7 +415,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
onDisconnect: () => {
if (voiceStore.getState().currentChannelId === null) return;
log.info("Leaving voice channel (widget disconnect)");
voiceSessionLeave();
voiceSessionLeave(false); // false: we send voice_leave below
leaveVoiceChannel();
ws.send({ type: "voice_leave", payload: {} });
},
+2 -2
View File
@@ -80,8 +80,8 @@ func (s *SFU) NewPeerConnection() (*webrtc.PeerConnection, error) {
})
}
// Add TURN server if enabled.
if s.config.TURNEnabled && s.config.TURNPort > 0 {
// Add TURN server if enabled and secret is configured.
if s.config.TURNEnabled && s.config.TURNPort > 0 && s.config.TURNSecret != "" {
pcConfig.ICEServers = append(pcConfig.ICEServers, webrtc.ICEServer{
URLs: []string{"turn:localhost:" + strconv.Itoa(s.config.TURNPort)},
Username: "owncord",
+5
View File
@@ -40,6 +40,11 @@ func (h *Hub) setupICEMonitor(c *Client, channelID int64) {
case webrtc.ICEConnectionStateFailed:
slog.Warn("ICE connection failed, cleaning up voice", "user_id", c.userID, "channel_id", channelID)
h.handleVoiceLeave(c)
case webrtc.ICEConnectionStateClosed:
// Closed means the PC was shut down (client destroyed it).
// Clean up server-side voice state as a safety net.
slog.Info("ICE connection closed, cleaning up voice", "user_id", c.userID, "channel_id", channelID)
h.handleVoiceLeave(c)
case webrtc.ICEConnectionStateDisconnected:
// Disconnected is transient — ICE may recover.
// Log but don't clean up immediately.