diff --git a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx index f618c75..94c47ed 100644 --- a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx +++ b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx @@ -43,6 +43,7 @@ import { useLiveStreamEnabled } from '@/lib/liveStream'; import { getDefaultSessionMode } from '@/lib/sessionDefaults'; import { useWebRTCHostAPI } from '@/hooks/useWebRTCHostAPI'; import { useWebRTCHostSFUAPI } from '@/hooks/useWebRTCHostSFUAPI'; +import { useAutoStopServerStream } from '@/hooks/useAutoStopServerStream'; import { useAudioMixer } from '@/hooks/useAudioMixer'; import { useInputInjection } from '@/hooks/useInputInjection'; import { @@ -471,6 +472,10 @@ export function CapturePreview({ }; }, [isHosting, stopHosting]); + // Stop the server egress if hosting ends while it is still running, so a host + // publish drop doesn't strand an orphaned room-composite egress on the SFU. + useAutoStopServerStream(isHosting, isServerStreaming, stopServerStream); + // Get source dimensions for cursor scaling const sourceDimensions = useMemo(() => { if (!stream) return { width: 1920, height: 1080 }; diff --git a/apps/desktop/src/renderer/hooks/useAutoStopServerStream.test.ts b/apps/desktop/src/renderer/hooks/useAutoStopServerStream.test.ts new file mode 100644 index 0000000..a475845 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useAutoStopServerStream.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useAutoStopServerStream } from './useAutoStopServerStream'; + +describe('useAutoStopServerStream', () => { + let stop: ReturnType; + + beforeEach(() => { + stop = vi.fn(); + }); + + it('stops the egress when hosting ends while a server stream is running', () => { + const { rerender } = renderHook( + ({ hosting, streaming }) => useAutoStopServerStream(hosting, streaming, stop), + { initialProps: { hosting: true, streaming: true } } + ); + expect(stop).not.toHaveBeenCalled(); + + // Host publish drops (e.g. dead NIC) — egress is still tracked. + rerender({ hosting: false, streaming: true }); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it('does not stop when hosting ends but no server stream is running', () => { + const { rerender } = renderHook( + ({ hosting, streaming }) => useAutoStopServerStream(hosting, streaming, stop), + { initialProps: { hosting: true, streaming: false } } + ); + rerender({ hosting: false, streaming: false }); + expect(stop).not.toHaveBeenCalled(); + }); + + it('does not fire on mount (no host->not-host transition yet)', () => { + renderHook(() => useAutoStopServerStream(false, true, stop)); + expect(stop).not.toHaveBeenCalled(); + }); + + it('does not fire while hosting stays active as the server stream toggles', () => { + const { rerender } = renderHook( + ({ hosting, streaming }) => useAutoStopServerStream(hosting, streaming, stop), + { initialProps: { hosting: true, streaming: false } } + ); + rerender({ hosting: true, streaming: true }); + rerender({ hosting: true, streaming: false }); + expect(stop).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/useAutoStopServerStream.ts b/apps/desktop/src/renderer/hooks/useAutoStopServerStream.ts new file mode 100644 index 0000000..17b3fa6 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useAutoStopServerStream.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from 'react'; + +/** + * Stops a running server-side egress when hosting ends. + * + * A host publish drop (flaky network / dead secondary NIC) otherwise strands + * the room-composite egress: LiveKit keeps compositing the now-empty room, + * burning SFU CPU and pushing dead/frozen frames to every platform until the + * dead publisher finally times out server-side (minutes later). This stops the + * egress as soon as the host's connection ends. + * + * Fires only on the hosting `true -> false` transition. A user-initiated "stop + * server stream" clears the egress first (`isServerStreaming -> false`), so this + * never double-stops, and it never fires before hosting has actually started. + */ +export function useAutoStopServerStream( + isHosting: boolean, + isServerStreaming: boolean, + stopServerStream: () => Promise +): void { + const wasHosting = useRef(false); + + useEffect(() => { + if (wasHosting.current && !isHosting && isServerStreaming) { + void stopServerStream(); + } + wasHosting.current = isHosting; + }, [isHosting, isServerStreaming, stopServerStream]); +}