more rum debugging
Build and Publish / Metadata (push) Successful in 1s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 25s

This commit is contained in:
mats committed 2026-09-22 15:20:42 +02:00
1 parent 40ebe5e6f6
commit cfbee14af9
2 files changed
+154 -49

No files matched your search

+153 -49
View File
@@ -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) {
<video autoplay playsinline></video>`);
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);
+1
View File
@@ -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);