From cfbee14af900c4f5b248d1820f5801dab1111153 Mon Sep 17 00:00:00 2001 From: matst80 Date: Tue, 22 Sep 2026 15:20:42 +0200 Subject: [PATCH] more rum debugging --- public/app.js | 202 +++++++++++++++++++++++++++++++++++------------ public/style.css | 1 + 2 files changed, 154 insertions(+), 49 deletions(-) diff --git a/public/app.js b/public/app.js index 9cc0a1e..b5cab26 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,32 @@ import * as matrixcs from 'matrix-js-sdk'; import cytoscape from 'cytoscape'; +function captureRumIssue(message, severity = 'ERROR', extra = {}) { + const rum = globalThis.OtelRUM?.last; + if (!rum || typeof rum.captureMessage !== 'function') return; + try { + rum.captureMessage(message, severity, extra); + } catch (err) { + console.warn('RUM capture failed', err); + } +} + +window.addEventListener('error', (event) => { + captureRumIssue(event.message || 'Unhandled browser error', 'ERROR', { + source: event.filename, + line: event.lineno, + column: event.colno, + }); +}); + +window.addEventListener('unhandledrejection', (event) => { + const reason = event.reason; + captureRumIssue(`Unhandled promise rejection: ${reason?.message || reason || 'unknown error'}`, 'ERROR', { + name: reason?.name, + stack: reason?.stack, + }); +}); + // --------------------------------------------------------------------------- // state // --------------------------------------------------------------------------- @@ -18,8 +44,8 @@ const S = { name: localStorage.getItem(LS_NAME) || '', peers: new Map(), // id -> peer record micStream: null, - screenStream: null, - screenSenders: new Map(), // peerId -> [RTCRtpSender] + screenStreams: new Map(), // streamId -> local MediaStream + screenSenders: new Map(), // peerId -> Map(streamId -> RTCRtpSender[]) audioCtx: null, me: { x: 50, y: 50 }, muted: false, @@ -150,6 +176,7 @@ async function populateAudioInputs() { if (selected && !select.value) localStorage.removeItem(LS_AUDIO_INPUT); } catch (err) { console.warn('enumerating audio inputs failed', err); + captureRumIssue('Could not enumerate audio inputs', 'WARNING', { error: err?.message || String(err) }); } } @@ -248,12 +275,13 @@ async function fetchRooms() { } } catch (err) { console.warn('fetchRooms failed', err); + captureRumIssue('Could not refresh voice channel rosters', 'WARNING', { error: err?.message || String(err) }); } } function leaveVoice(reason) { if (!S.room && !S.ws) return; - if (S.sharing) stopScreenShare(true); + if (S.sharing) stopScreenShare(undefined, true); for (const peer of S.peers.values()) destroyPeer(peer); S.peers.clear(); if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; } @@ -365,11 +393,19 @@ function openSignaling(room) { const url = `${proto}://${location.host}/ws?room=${encodeURIComponent(room)}&name=${encodeURIComponent(S.name)}`; const ws = new WebSocket(url); S.ws = ws; - ws.onerror = () => { S.connecting = false; setStatus('connection failed', 'error'); reject(new Error('websocket failed')); }; + ws.onerror = () => { + S.connecting = false; + setStatus('connection failed', 'error'); + captureRumIssue('Voice signaling connection failed', 'ERROR', { room }); + reject(new Error('websocket failed')); + }; ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } - try { handleSignal(msg); } catch (err) { console.error('signal handler error', msg.type, err); } + try { handleSignal(msg); } catch (err) { + console.error('signal handler error', msg.type, err); + captureRumIssue('Voice signaling message handler failed', 'ERROR', { type: msg.type, error: err?.message || String(err) }); + } }; ws.onclose = () => { if (S.pingTimer) { clearInterval(S.pingTimer); S.pingTimer = null; } @@ -445,13 +481,19 @@ function handleSignal(msg) { case 'offer': case 'answer': { const peer = ensurePC(S.peers.get(msg.from) || createOrphanPeer(msg.from), false); - onDescription(peer, msg.sdp).catch((e) => console.error('description failed', e)); + onDescription(peer, msg.sdp).catch((e) => { + console.error('description failed', e); + captureRumIssue('WebRTC session description failed', 'ERROR', { peerId: peer.id, type: msg.type, error: e?.message || String(e) }); + }); break; } case 'candidate': { const peer = S.peers.get(msg.from); if (!peer || !peer.pc) return; // candidates only after description path created pc - onCandidate(peer, msg.candidate).catch((e) => console.error('candidate failed', e)); + onCandidate(peer, msg.candidate).catch((e) => { + console.error('candidate failed', e); + captureRumIssue('WebRTC ICE candidate failed', 'WARNING', { peerId: peer.id, error: e?.message || String(e) }); + }); break; } case 'reset': { @@ -476,7 +518,7 @@ function makePeerRecord(p) { id: p.id, name: p.name, x: p.x, y: p.y, state: p.state || { muted: false, deafened: false, sharing: false }, pc: null, polite: false, makingOffer: false, ignoreOffer: false, - pendingCandidates: [], micStream: null, screenStream: null, + pendingCandidates: [], micStream: null, screenStreams: new Map(), gain: null, panner: null, analyser: null, vol: 1, speaking: false, resetAttempts: 0, els: {}, @@ -508,6 +550,7 @@ function ensurePC(peer, initiator) { send({ type: 'offer', to: peer.id, sdp: pc.localDescription }); } catch (err) { console.error('negotiation failed', err); + captureRumIssue('WebRTC negotiation failed', 'ERROR', { peerId: peer.id, error: err?.message || String(err) }); } finally { peer.makingOffer = false; } @@ -515,10 +558,23 @@ function ensurePC(peer, initiator) { pc.onicecandidate = ({ candidate }) => { if (candidate) send({ type: 'candidate', to: peer.id, candidate }); }; + pc.onicecandidateerror = (event) => { + captureRumIssue('WebRTC ICE candidate error', 'WARNING', { + peerId: peer.id, + errorCode: event.errorCode, + errorText: event.errorText, + url: event.url, + }); + }; pc.onconnectionstatechange = () => { renderTiles(); if (pc.connectionState === 'failed') { console.warn('peer connection failed, rebuilding it from scratch', peer.id); + captureRumIssue('WebRTC peer connection failed', 'ERROR', { + peerId: peer.id, + state: pc.connectionState, + resetAttempts: peer.resetAttempts, + }); recreatePeerConnection(peer, { announce: true }); } else if (pc.connectionState === 'connected') { peer.resetAttempts = 0; // a successful connection clears the failure count @@ -534,12 +590,16 @@ function ensurePC(peer, initiator) { } // send my screen share if I am currently sharing when this peer connects - if (S.sharing && S.screenStream) { - const senders = []; - for (const track of S.screenStream.getTracks()) { - senders.push(pc.addTrack(track, S.screenStream)); + if (S.sharing) { + const peerSenders = new Map(); + for (const [streamId, stream] of S.screenStreams) { + const senders = []; + for (const track of stream.getTracks()) { + senders.push(pc.addTrack(track, stream)); + } + peerSenders.set(streamId, senders); } - S.screenSenders.set(peer.id, senders); + S.screenSenders.set(peer.id, peerSenders); } if (initiator) pc.onnegotiationneeded(); @@ -561,6 +621,10 @@ function recreatePeerConnection(peer, { announce }) { peer.resetAttempts++; if (peer.resetAttempts > RESET_MAX_ATTEMPTS) { toast(`Connection to ${peer.name} keeps failing — try leaving and rejoining the channel`, 'error'); + captureRumIssue('WebRTC peer connection could not be rebuilt', 'ERROR', { + peerId: peer.id, + resetAttempts: peer.resetAttempts, + }); return; } send({ type: 'reset', to: peer.id }); @@ -573,7 +637,7 @@ function recreatePeerConnection(peer, { announce }) { detachSpatial(peer); if (peer.audioEl) { try { peer.audioEl.pause(); peer.audioEl.srcObject = null; } catch {} peer.audioEl = null; } peer.micStream = null; - peer.screenStream = null; + peer.screenStreams.clear(); S.screenSenders.delete(peer.id); renderScreens(); renderTiles(); @@ -588,7 +652,10 @@ async function onDescription(peer, desc) { await pc.setRemoteDescription(desc); // modern browsers roll back implicitly when polite for (const c of peer.pendingCandidates.splice(0)) { - await pc.addIceCandidate(c).catch((e) => console.warn('buffered candidate failed', e)); + await pc.addIceCandidate(c).catch((e) => { + console.warn('buffered candidate failed', e); + captureRumIssue('WebRTC buffered ICE candidate failed', 'WARNING', { peerId: peer.id, error: e?.message || String(e) }); + }); } if (desc.type === 'offer') { await pc.setLocalDescription(); @@ -613,7 +680,7 @@ function destroyPeer(peer) { peer.audioEl = null; } peer.micStream = null; - peer.screenStream = null; + peer.screenStreams.clear(); peer.els = {}; renderScreens(); } @@ -626,10 +693,13 @@ function onRemoteTrack(peer, ev) { const stream = (ev.streams && ev.streams[0]) ? ev.streams[0] : new MediaStream([track]); if (track.kind === 'video') { - const isNewScreenShare = peer.screenStream !== stream; - peer.screenStream = stream; + const isNewScreenShare = !peer.screenStreams.has(stream.id); + peer.screenStreams.set(stream.id, stream); track.addEventListener('ended', () => { - if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); } + if (peer.screenStreams.get(stream.id) === stream) { + peer.screenStreams.delete(stream.id); + renderScreens(); + } }); renderScreens(); if (isNewScreenShare) playRetroCue('share'); @@ -707,10 +777,6 @@ function orientListener() { // screen share (local) // --------------------------------------------------------------------------- async function startScreenShare() { - if (S.sharing) { - stopScreenShare(); - return; - } if (!S.room) { toast('Join a voice channel first to share your screen', 'warn'); return; @@ -740,11 +806,12 @@ async function startScreenShare() { return; } console.error('Failed to get display media:', fallbackErr); + captureRumIssue('WebRTC display media capture failed', 'ERROR', { error: fallbackErr?.message || String(fallbackErr) }); toast(`Could not share screen: ${fallbackErr.message || fallbackErr}`, 'error'); return; } } - S.screenStream = stream; + S.screenStreams.set(stream.id, stream); S.sharing = true; playRetroCue('share'); send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: true } }); @@ -752,7 +819,7 @@ async function startScreenShare() { for (const peer of S.peers.values()) addScreenToPeer(peer); const videoTrack = stream.getVideoTracks()[0]; if (videoTrack) { - videoTrack.addEventListener('ended', () => stopScreenShare()); + videoTrack.addEventListener('ended', () => stopScreenShare(stream)); } renderScreens(); renderTiles(); @@ -761,28 +828,43 @@ async function startScreenShare() { } function addScreenToPeer(peer) { - if (!peer.pc || !S.screenStream) return; - const senders = []; - for (const track of S.screenStream.getTracks()) { - senders.push(peer.pc.addTrack(track, S.screenStream)); + if (!peer.pc || !S.sharing) return; + const peerSenders = new Map(); + for (const [streamId, stream] of S.screenStreams) { + const senders = []; + for (const track of stream.getTracks()) { + senders.push(peer.pc.addTrack(track, stream)); + } + peerSenders.set(streamId, senders); } - S.screenSenders.set(peer.id, senders); // renegotiation fires automatically + S.screenSenders.set(peer.id, peerSenders); // renegotiation fires automatically } -function stopScreenShare(silent) { - if (!S.sharing) return; - S.sharing = false; - for (const [peerId, senders] of S.screenSenders) { +function stopScreenShare(stream, silent) { + const streams = stream ? [stream] : [...S.screenStreams.values()]; + if (!streams.length) return; + for (const [peerId, peerSenders] of S.screenSenders) { const peer = S.peers.get(peerId); - if (peer && peer.pc) for (const s of senders) { try { peer.pc.removeTrack(s); } catch {} } + if (!peer || !peer.pc) continue; + for (const localStream of streams) { + for (const sender of peerSenders.get(localStream.id) || []) { + try { peer.pc.removeTrack(sender); } catch {} + } + peerSenders.delete(localStream.id); + } } - S.screenSenders.clear(); - if (S.screenStream) { S.screenStream.getTracks().forEach((t) => t.stop()); S.screenStream = null; } - send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: false } }); + for (const localStream of streams) { + if (S.screenStreams.get(localStream.id) !== localStream) continue; + localStream.getTracks().forEach((t) => t.stop()); + S.screenStreams.delete(localStream.id); + } + S.sharing = S.screenStreams.size > 0; + if (!S.sharing) S.screenSenders.clear(); + send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: S.sharing } }); renderScreens(); renderTiles(); - for (const btn of shareButtons) btn.classList.remove('active'); - if (!silent) toast('Screen share stopped'); + for (const btn of shareButtons) btn.classList.toggle('active', S.sharing); + if (!silent) toast(S.sharing ? 'Screen share stopped' : 'Screen sharing stopped'); } // --------------------------------------------------------------------------- @@ -1025,10 +1107,14 @@ const poppedWindows = new Map(); // key -> {win, name} function activeShares() { const shares = []; - if (S.sharing && S.screenStream) shares.push({ key: 'me', name: `${S.name} (you)`, stream: S.screenStream }); + for (const [streamId, stream] of S.screenStreams) { + shares.push({ key: `me:${streamId}`, name: `${S.name} (you)`, stream, local: true }); + } for (const peer of S.peers.values()) { - if (peer.state.sharing && peer.screenStream) { - shares.push({ key: peer.id, name: peer.name, stream: peer.screenStream }); + if (peer.state.sharing) { + for (const stream of peer.screenStreams.values()) { + shares.push({ key: `${peer.id}:${stream.id}`, name: peer.name, stream }); + } } } return shares; @@ -1062,6 +1148,15 @@ function renderScreens() { label.className = 'label'; label.textContent = `${share.name} — popped out`; ph.append(btn, label); + if (share.local) { + const stop = document.createElement('button'); + stop.className = 'pop-btn stop-share-btn'; + stop.title = 'Stop this screen share'; + stop.appendChild(mIcon('stop_circle')); + stop.appendChild(document.createTextNode(' stop')); + stop.onclick = () => stopScreenShare(share.stream); + ph.appendChild(stop); + } els.screens.appendChild(ph); continue; } @@ -1071,7 +1166,7 @@ function renderScreens() { const video = document.createElement('video'); video.autoplay = true; video.playsInline = true; - video.muted = share.key === 'me'; + video.muted = share.local; video.srcObject = share.stream; video.play().catch(() => {}); const label = document.createElement('div'); @@ -1088,6 +1183,15 @@ function renderScreens() { pop.appendChild(mIcon('open_in_new')); pop.onclick = () => popOutShare(share); card.append(video, label, pop); + if (share.local) { + const stop = document.createElement('button'); + stop.className = 'pop-btn stop-share-btn'; + stop.title = 'Stop this screen share'; + stop.appendChild(mIcon('stop_circle')); + stop.appendChild(document.createTextNode(' stop')); + stop.onclick = () => stopScreenShare(share.stream); + card.appendChild(stop); + } els.screens.appendChild(card); } updateScreenTileIndicators(); @@ -1099,11 +1203,11 @@ function updateScreenTileIndicators() { for (const card of els.screens.querySelectorAll('.screen-card[data-share-key]')) { const key = card.dataset.shareKey; let speaking = false, muted = false; - if (key === 'me') { + if (key.startsWith('me:')) { speaking = els.meAvatar.classList.contains('speaking'); muted = S.muted; } else { - const peer = S.peers.get(key); + const peer = S.peers.get(key.split(':', 1)[0]); if (peer) { speaking = peer.speaking; muted = peer.state.muted; } } card.classList.toggle('speaking', speaking); @@ -1124,7 +1228,7 @@ function popOutShare(share) { `); win.document.close(); const v = win.document.querySelector('video'); - v.muted = share.key === 'me'; + v.muted = share.local; v.srcObject = share.stream; v.play().catch(() => {}); poppedWindows.set(share.key, { win, name: share.name }); @@ -1694,7 +1798,7 @@ function toggleDeafen() { for (const btn of micButtons) btn.onclick = toggleMic; for (const btn of deafenButtons) btn.onclick = toggleDeafen; -for (const btn of shareButtons) btn.onclick = () => (S.sharing ? stopScreenShare() : startScreenShare()); +for (const btn of shareButtons) btn.onclick = startScreenShare; $('#btn-settings').onclick = openSettings; els.nick.value = S.name; @@ -1865,7 +1969,7 @@ window.addEventListener('keydown', (ev) => { if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA') return; if (ev.key === 'm') toggleMic(); if (ev.key === 'd') toggleDeafen(); - if (ev.key === 's') (S.sharing ? stopScreenShare() : startScreenShare()); + if (ev.key === 's') startScreenShare(); }); window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} }); navigator.mediaDevices?.addEventListener('devicechange', populateAudioInputs); diff --git a/public/style.css b/public/style.css index 7c53e3e..197e4d6 100644 --- a/public/style.css +++ b/public/style.css @@ -216,6 +216,7 @@ body.sidebar-collapsed #btn-sidebar-expand { display: grid; } } .pop-btn .material-symbols-outlined { font-size: 16px; } .pop-btn:hover { background: var(--brand); } +.screen-card .stop-share-btn { right: 96px; } .screen-card { position: relative; background: #000; border-radius: var(--radius); overflow: hidden; flex: 1 1 480px; max-width: 100%; box-shadow: 0 8px 24px rgba(0,0,0,.4);