Skip to content

Commit 55b3a49

Browse files
authored
Add connectionCount tracking and stop auto refetching after timeout (#1756)
1 parent 75b2bf3 commit 55b3a49

File tree

4 files changed

+267
-0
lines changed

4 files changed

+267
-0
lines changed

.changeset/nine-deers-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"livekit-client": patch
3+
---
4+
5+
Add connectionCount tracking and stop auto refetching after timeout

src/room/RegionUrlProvider.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,4 +828,179 @@ describe('RegionUrlProvider', () => {
828828
expect(region2).toBeNull(); // Filtered out because same URL was already attempted
829829
});
830830
});
831+
832+
describe('connection tracking and auto-refetch cleanup', () => {
833+
beforeEach(() => {
834+
// Reset connection tracking maps
835+
// @ts-ignore - accessing private static field for testing
836+
RegionUrlProvider.connectionTrackers = new Map();
837+
});
838+
839+
it('stops auto-refetch 30s after last connection disconnects', async () => {
840+
const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
841+
const mockSettings = createMockRegionSettings([
842+
{ region: 'us-west', url: 'wss://us-west.livekit.cloud' },
843+
]);
844+
845+
fetchMock.mockResolvedValue(
846+
createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }),
847+
);
848+
849+
// Initial fetch to start auto-refetch
850+
await provider.getNextBestRegionUrl();
851+
expect(fetchMock).toHaveBeenCalledTimes(1);
852+
853+
const hostname = provider.getServerUrl().hostname;
854+
855+
// Simulate connection
856+
provider.notifyConnected();
857+
858+
// Verify auto-refetch is running
859+
const timersBeforeDisconnect = vi.getTimerCount();
860+
expect(timersBeforeDisconnect).toBeGreaterThan(0);
861+
862+
// Simulate disconnect
863+
provider.notifyDisconnected();
864+
865+
// Should schedule cleanup timeout (30s)
866+
// Advance time by 29s - refetch should still be running
867+
vi.advanceTimersByTime(29000);
868+
expect(fetchMock).toHaveBeenCalledTimes(1); // No additional fetches
869+
870+
// Advance by 1 more second (total 30s) - cleanup should trigger
871+
await vi.advanceTimersByTimeAsync(1000);
872+
873+
// Auto-refetch should be stopped, so no new timers for refetch
874+
// @ts-ignore - accessing private static field for testing
875+
const refetchTimeout = RegionUrlProvider.settingsTimeouts.get(hostname);
876+
expect(refetchTimeout).toBeUndefined();
877+
});
878+
879+
it('cancels cleanup when reconnecting before 30s delay', async () => {
880+
const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
881+
const mockSettings = createMockRegionSettings([
882+
{ region: 'us-west', url: 'wss://us-west.livekit.cloud' },
883+
]);
884+
885+
fetchMock.mockResolvedValue(
886+
createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }),
887+
);
888+
889+
await provider.getNextBestRegionUrl();
890+
const hostname = provider.getServerUrl().hostname;
891+
892+
// Connect and disconnect
893+
provider.notifyConnected();
894+
provider.notifyDisconnected();
895+
896+
// Advance time by 15s (less than 30s)
897+
vi.advanceTimersByTime(15000);
898+
899+
// Reconnect before cleanup triggers
900+
provider.notifyConnected();
901+
902+
// @ts-ignore - accessing private static field for testing
903+
const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
904+
expect(tracker?.cleanupTimeout).toBeUndefined(); // Cleanup should be cancelled
905+
906+
// Advance past the original 30s mark
907+
vi.advanceTimersByTime(20000);
908+
909+
// Auto-refetch should still be running
910+
// @ts-ignore - accessing private static field for testing
911+
const refetchTimeout = RegionUrlProvider.settingsTimeouts.get(hostname);
912+
expect(refetchTimeout).toBeDefined();
913+
});
914+
915+
it('tracks multiple connections correctly', async () => {
916+
const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
917+
const mockSettings = createMockRegionSettings([
918+
{ region: 'us-west', url: 'wss://us-west.livekit.cloud' },
919+
]);
920+
921+
fetchMock.mockResolvedValue(
922+
createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }),
923+
);
924+
925+
await provider.getNextBestRegionUrl();
926+
const hostname = provider.getServerUrl().hostname;
927+
928+
// Simulate 3 connections
929+
provider.notifyConnected();
930+
provider.notifyConnected();
931+
provider.notifyConnected();
932+
933+
// @ts-ignore - accessing private static field for testing
934+
const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
935+
expect(tracker?.connectionCount).toBe(3);
936+
937+
// Disconnect first connection
938+
provider.notifyDisconnected();
939+
940+
// @ts-ignore - accessing private static field for testing
941+
expect(tracker?.connectionCount).toBe(2);
942+
943+
// Should NOT schedule cleanup yet (still have active connections)
944+
expect(tracker?.cleanupTimeout).toBeUndefined();
945+
946+
// Disconnect second connection
947+
provider.notifyDisconnected();
948+
// @ts-ignore - accessing private static field for testing
949+
expect(tracker?.connectionCount).toBe(1);
950+
951+
// Disconnect last connection
952+
provider.notifyDisconnected();
953+
// @ts-ignore - accessing private static field for testing
954+
expect(tracker?.connectionCount).toBe(0);
955+
956+
// NOW cleanup should be scheduled
957+
expect(tracker?.cleanupTimeout).toBeDefined();
958+
});
959+
960+
it('handles disconnect without prior connect gracefully', () => {
961+
const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
962+
const hostname = provider.getServerUrl().hostname;
963+
964+
// Disconnect without connect should not throw
965+
expect(() => {
966+
provider.notifyDisconnected();
967+
}).not.toThrow();
968+
969+
// Should not create a tracker
970+
// @ts-ignore - accessing private static field for testing
971+
const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
972+
expect(tracker).toBeUndefined();
973+
});
974+
975+
it('clears cleanup timeout when scheduling new cleanup', async () => {
976+
const provider = new RegionUrlProvider('wss://test.livekit.cloud', 'token');
977+
const mockSettings = createMockRegionSettings([
978+
{ region: 'us-west', url: 'wss://us-west.livekit.cloud' },
979+
]);
980+
981+
fetchMock.mockResolvedValue(
982+
createMockResponse(200, mockSettings, { 'Cache-Control': 'max-age=100' }),
983+
);
984+
985+
await provider.getNextBestRegionUrl();
986+
const hostname = provider.getServerUrl().hostname;
987+
988+
// Connect and disconnect (schedules cleanup)
989+
provider.notifyConnected();
990+
provider.notifyDisconnected();
991+
992+
// @ts-ignore - accessing private static field for testing
993+
const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
994+
const firstCleanupTimeout = tracker?.cleanupTimeout;
995+
expect(firstCleanupTimeout).toBeDefined();
996+
997+
// Reconnect and disconnect again (should cancel first and schedule new)
998+
provider.notifyConnected();
999+
provider.notifyDisconnected();
1000+
1001+
const secondCleanupTimeout = tracker?.cleanupTimeout;
1002+
expect(secondCleanupTimeout).toBeDefined();
1003+
expect(secondCleanupTimeout).not.toBe(firstCleanupTimeout);
1004+
});
1005+
});
8311006
});

src/room/RegionUrlProvider.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,26 @@ import { ConnectionError, ConnectionErrorReason } from './errors';
55
import { extractMaxAgeFromRequestHeaders, isCloud } from './utils';
66

77
export const DEFAULT_MAX_AGE_MS = 5_000;
8+
export const STOP_REFETCH_DELAY_MS = 30_000;
89

910
type CachedRegionSettings = {
1011
regionSettings: RegionSettings;
1112
updatedAtInMs: number;
1213
maxAgeInMs: number;
1314
};
1415

16+
type ConnectionTracker = {
17+
connectionCount: number;
18+
cleanupTimeout?: ReturnType<typeof setTimeout>;
19+
};
20+
1521
export class RegionUrlProvider {
1622
private static readonly cache: Map<string, CachedRegionSettings> = new Map();
1723

1824
private static settingsTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
1925

26+
private static connectionTrackers: Map<string, ConnectionTracker> = new Map();
27+
2028
private static fetchLock = new Mutex();
2129

2230
private static async fetchRegionSettings(
@@ -98,6 +106,75 @@ export class RegionUrlProvider {
98106
RegionUrlProvider.scheduleRefetch(url, token, settings.maxAgeInMs);
99107
}
100108

109+
private static stopRefetch(hostname: string) {
110+
const timeout = RegionUrlProvider.settingsTimeouts.get(hostname);
111+
if (timeout) {
112+
clearTimeout(timeout);
113+
RegionUrlProvider.settingsTimeouts.delete(hostname);
114+
}
115+
}
116+
117+
private static scheduleCleanup(hostname: string) {
118+
let tracker = RegionUrlProvider.connectionTrackers.get(hostname);
119+
if (!tracker) {
120+
return;
121+
}
122+
123+
// Cancel any existing cleanup timeout
124+
if (tracker.cleanupTimeout) {
125+
clearTimeout(tracker.cleanupTimeout);
126+
}
127+
128+
// Schedule cleanup to stop refetch after delay
129+
tracker.cleanupTimeout = setTimeout(() => {
130+
const currentTracker = RegionUrlProvider.connectionTrackers.get(hostname);
131+
if (currentTracker && currentTracker.connectionCount === 0) {
132+
log.debug('stopping region refetch after disconnect delay', { hostname });
133+
RegionUrlProvider.stopRefetch(hostname);
134+
}
135+
if (currentTracker) {
136+
currentTracker.cleanupTimeout = undefined;
137+
}
138+
}, STOP_REFETCH_DELAY_MS);
139+
}
140+
141+
private static cancelCleanup(hostname: string) {
142+
const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
143+
if (tracker?.cleanupTimeout) {
144+
clearTimeout(tracker.cleanupTimeout);
145+
tracker.cleanupTimeout = undefined;
146+
}
147+
}
148+
149+
notifyConnected() {
150+
const hostname = this.serverUrl.hostname;
151+
let tracker = RegionUrlProvider.connectionTrackers.get(hostname);
152+
if (!tracker) {
153+
tracker = { connectionCount: 0 };
154+
RegionUrlProvider.connectionTrackers.set(hostname, tracker);
155+
}
156+
157+
tracker.connectionCount++;
158+
159+
// Cancel any scheduled cleanup since we have an active connection
160+
RegionUrlProvider.cancelCleanup(hostname);
161+
}
162+
163+
notifyDisconnected() {
164+
const hostname = this.serverUrl.hostname;
165+
const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
166+
if (!tracker) {
167+
return;
168+
}
169+
170+
tracker.connectionCount = Math.max(0, tracker.connectionCount - 1);
171+
172+
// If no more connections, schedule cleanup
173+
if (tracker.connectionCount === 0) {
174+
RegionUrlProvider.scheduleCleanup(hostname);
175+
}
176+
}
177+
101178
private serverUrl: URL;
102179

103180
private token: string;

src/room/Room.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,6 +944,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
944944
this.emit(RoomEvent.Connected);
945945
BackOffStrategy.getInstance().resetFailedConnectionAttempts(url);
946946
this.registerConnectionReconcile();
947+
948+
// Notify region provider about successful connection
949+
if (this.regionUrlProvider) {
950+
this.regionUrlProvider.notifyConnected();
951+
}
947952
};
948953

949954
/**
@@ -1557,6 +1562,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
15571562

15581563
this.regionUrl = undefined;
15591564

1565+
// Notify region provider about disconnect to potentially stop auto-refetch
1566+
if (this.regionUrlProvider) {
1567+
this.regionUrlProvider.notifyDisconnected();
1568+
}
1569+
15601570
try {
15611571
this.remoteParticipants.forEach((p) => {
15621572
p.trackPublications.forEach((pub) => {

0 commit comments

Comments
 (0)