more rum debugging
This commit is contained in:
1 parent
40ebe5e6f6
commit
cfbee14af9
2 files changed
+154
-49
No files matched your search
+153
-49
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user