Skip to content

Commit c2e520f

Browse files
committed
Fix #5391 (channels-list watchdog: HTTP validate + retry + reload on silence); fix screen-share fullscreen exit on transient track.onunmute (skip srcObject reassign when same live track is already rendering)
1 parent b6a89c1 commit c2e520f

2 files changed

Lines changed: 72 additions & 4 deletions

File tree

public/js/modules/app-socket.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,51 @@ _setupSocketListeners() {
102102
this.socket.emit('visibility-change', { visible: !document.hidden });
103103
this.socket.emit('get-channels');
104104
this.socket.emit('get-server-settings');
105+
106+
// (#5391) Watchdog: if the socket connects but channels-list never
107+
// arrives, the session is in a broken state that the auth middleware
108+
// didn't catch (DB hiccup mid-handshake, getEnrichedChannels throwing,
109+
// user row out of sync, etc.). The visible symptom is the sidebar
110+
// sitting empty forever and the user not knowing whether to refresh.
111+
// After 10 s of silence, fall back to a deterministic HTTP token check
112+
// and either retry once or kick to /login. Cleared as soon as
113+
// channels-list lands (see the channels-list handler below).
114+
if (this._channelsWatchdog) clearTimeout(this._channelsWatchdog);
115+
this._channelsListGotResponse = false;
116+
this._channelsWatchdog = setTimeout(async () => {
117+
if (this._channelsListGotResponse) return;
118+
try {
119+
const resp = await fetch('/api/auth/validate', {
120+
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('haven_token') || '') }
121+
});
122+
if (resp.status === 401 || resp.status === 404) {
123+
// Token is stale or user row gone — same outcome as a socket
124+
// 'Invalid token' / 'Session expired'. Kick to login.
125+
localStorage.removeItem('haven_token');
126+
localStorage.removeItem('haven_user');
127+
localStorage.removeItem('haven_sync_key');
128+
window.location.href = '/';
129+
return;
130+
}
131+
} catch {
132+
// Network failure — leave it alone, the user can refresh manually.
133+
return;
134+
}
135+
// Token is valid but channels never came. Retry once before giving up.
136+
if (!this._channelsListGotResponse && this.socket?.connected) {
137+
console.warn('[#5391] channels-list missing after 10s, retrying get-channels');
138+
this.socket.emit('get-channels');
139+
setTimeout(() => {
140+
if (!this._channelsListGotResponse) {
141+
// Server clearly can't fulfil get-channels for this session —
142+
// a full reload picks up any server-side fix and re-runs the
143+
// auth handshake from scratch.
144+
console.warn('[#5391] channels-list still missing after retry, forcing reload');
145+
window.location.reload();
146+
}
147+
}, 5000);
148+
}
149+
}, 10000);
105150
if (this.currentChannel) {
106151
this.socket.emit('enter-channel', { code: this.currentChannel });
107152
// Reset pagination — reconnect replaces message list
@@ -417,6 +462,12 @@ _setupSocketListeners() {
417462
});
418463

419464
this.socket.on('channels-list', (channels) => {
465+
// (#5391) Cancel the channels-not-arriving watchdog
466+
this._channelsListGotResponse = true;
467+
if (this._channelsWatchdog) {
468+
clearTimeout(this._channelsWatchdog);
469+
this._channelsWatchdog = null;
470+
}
420471
// Detect if currentChannel's code was rotated while disconnected (stale code).
421472
// Capture the channel's ID from the old list before overwriting it.
422473
let rotatedChannelId = null;

public/js/modules/app-voice.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -988,11 +988,28 @@ _handleScreenStream(userId, stream, { force = false } = {}) {
988988
const videoEl = tile.querySelector('video');
989989
// Force a layout reflow so the video element has real dimensions
990990
void videoEl.offsetHeight;
991-
// Force re-render if the same stream is re-assigned (otherwise it's a no-op → black screen)
992-
if (videoEl.srcObject === stream) {
993-
videoEl.srcObject = null;
991+
// Skip srcObject reassignment if the existing stream already wraps the
992+
// same live video track AND we're already getting frames. WebRTC fires
993+
// track.onunmute on every transient packet-loss / bitrate-adapt blip;
994+
// each fire calls back into here with `new MediaStream([sameTrack])`.
995+
// Reassigning srcObject in those moments kicks the browser out of
996+
// fullscreen (and pop-out, on some platforms) because the fullscreen
997+
// element's underlying media briefly "changes". Only swap if the track
998+
// is actually different or we don't have frames yet.
999+
const newVideoTrack = stream.getVideoTracks()[0] || null;
1000+
const curStream = videoEl.srcObject;
1001+
const curVideoTrack = curStream ? (curStream.getVideoTracks?.()[0] || null) : null;
1002+
const sameLiveTrack = newVideoTrack && curVideoTrack &&
1003+
newVideoTrack.id === curVideoTrack.id &&
1004+
newVideoTrack.readyState === 'live' &&
1005+
videoEl.videoWidth > 0;
1006+
if (!sameLiveTrack) {
1007+
// Force re-render if the same stream is re-assigned (otherwise it's a no-op → black screen)
1008+
if (videoEl.srcObject === stream) {
1009+
videoEl.srcObject = null;
1010+
}
1011+
videoEl.srcObject = stream;
9941012
}
995-
videoEl.srcObject = stream;
9961013
videoEl.play().catch(() => {});
9971014
// Also re-play when metadata loads (handles late-arriving tracks)
9981015
videoEl.onloadedmetadata = () => { videoEl.play().catch(() => {}); };

0 commit comments

Comments
 (0)