diff --git a/ui/public/embed/dograh-widget.js b/ui/public/embed/dograh-widget.js index 741cf189..65614cf7 100644 --- a/ui/public/embed/dograh-widget.js +++ b/ui/public/embed/dograh-widget.js @@ -633,6 +633,11 @@ // Request microphone permission try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + // Release any stream still held from a prior attempt before retaining + // the new one, so a re-entrant start can't leak the microphone. + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + } state.stream = stream; } catch (micError) { // Handle specific microphone permission errors @@ -660,6 +665,31 @@ } catch (error) { console.error('Dograh Widget: Failed to start call', error); + + // Release anything acquired before the failure so a retry starts clean. + // getUserMedia may have succeeded before a later step (WebSocket / + // negotiation) threw, which would otherwise leave the mic held and block + // the next getUserMedia(). Null the refs before close() so the peer/ws + // state handlers short-circuit instead of re-entering teardown. + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + state.stream = null; + } + if (state.pc) { + const pc = state.pc; + state.pc = null; + if (pc.signalingState !== 'closed') { + pc.close(); + } + } + if (state.ws) { + const ws = state.ws; + state.ws = null; + if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + ws.close(); + } + } + updateStatus('failed', 'Connection failed', error.message || 'Please check your microphone and try again'); // Trigger error callback diff --git a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx index 3a7ee3ee..f93e5f1b 100644 --- a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx +++ b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx @@ -70,6 +70,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia const audioRef = useRef(null); const pcRef = useRef(null); const wsRef = useRef(null); + const localStreamRef = useRef(null); const timeStartRef = useRef(null); const onNodeTransitionRef = useRef(onNodeTransition); const connectionActiveRef = useRef(connectionActive); @@ -153,6 +154,16 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia } }, []); + const stopLocalStream = useCallback(() => { + // Release the microphone so the device is freed for a subsequent call. + // Stopping the sender tracks via pc.getSenders() alone can leave the + // browser holding the mic, blocking the next getUserMedia(). + if (localStreamRef.current) { + localStreamRef.current.getTracks().forEach((track) => track.stop()); + localStreamRef.current = null; + } + }, []); + const cleanupConnection = useCallback((options: CleanupConnectionOptions = {}) => { const graceful = options.graceful ?? true; const status = options.status ?? (graceful ? 'idle' : 'failed'); @@ -173,10 +184,12 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia wsRef.current = null; } + stopLocalStream(); + if (options.closePeerConnection !== false) { closePeerConnection(pcRef.current, options.delayPeerClose ?? false); } - }, [closePeerConnection]); + }, [closePeerConnection, stopLocalStream]); const createPeerConnection = () => { // Build ICE servers list @@ -743,6 +756,10 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia if (constraints.audio) { try { const stream = await navigator.mediaDevices.getUserMedia(constraints); + // Release any stream still held from a prior attempt before + // retaining the new one, so re-entry can't leak a device. + stopLocalStream(); + localStreamRef.current = stream; stream.getTracks().forEach((track) => { pc.addTrack(track, stream); }); @@ -770,6 +787,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia // Cleanup on unmount useEffect(() => { return () => { + stopLocalStream(); if (wsRef.current) { wsRef.current.close(); } @@ -777,7 +795,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia pcRef.current.close(); } }; - }, []); + }, [stopLocalStream]); return { audioRef,