Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/renderer/hooks/useAutoStopServerStream.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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();
});
});
29 changes: 29 additions & 0 deletions apps/desktop/src/renderer/hooks/useAutoStopServerStream.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>
): void {
const wasHosting = useRef(false);

useEffect(() => {
if (wasHosting.current && !isHosting && isServerStreaming) {
void stopServerStream();
}
wasHosting.current = isHosting;
}, [isHosting, isServerStreaming, stopServerStream]);
}
Loading