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
44 changes: 43 additions & 1 deletion tauri/src/components/ui/call-center.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ export function CallCenter() {

return (
<div className="flex flex-col items-center w-full max-w-sm mx-auto bg-white pt-4 mb-4">
{/* Reconnecting Banner */}
{callTokens.isReconnecting && (
<div className="w-full bg-amber-100 border border-amber-300 rounded-md px-3 py-2 mb-3 flex items-center gap-2">
<div className="animate-spin h-4 w-4 border-2 border-amber-600 border-t-transparent rounded-full" />
<span className="text-xs font-medium text-amber-800">Reconnecting...</span>
</div>
)}

<div className="w-full">
{/* Call Timer */}
{callTokens && (
Expand Down Expand Up @@ -594,7 +602,7 @@ function CameraIcon() {
}

function MediaDevicesSettings() {
const { callTokens, setCallTokens } = useStore();
const { callTokens, setCallTokens, updateCallTokens, livekitUrl } = useStore();
const { state: roomState } = useRoomContext();
const { localParticipant } = useLocalParticipant();
const { isNoiseFilterPending, setNoiseFilterEnabled, isNoiseFilterEnabled } = useKrispNoiseFilter({
Expand All @@ -610,12 +618,46 @@ function MediaDevicesSettings() {

const room = useRoomContext();
const [roomConnected, setRoomConnected] = useState(false);

useEffect(() => {
room.on(RoomEvent.Connected, () => {
setRoomConnected(true);
});
}, [room]);

// Listen to connection state changes and handle reconnection
useEffect(() => {
const handleConnectionStateChange = async (state: ConnectionState) => {
console.log("Connection state changed:", state);

if (state === ConnectionState.Disconnected && callTokens) {
// Room disconnected but we still have callTokens - try to reconnect
console.log("Room disconnected, attempting to reconnect...");
updateCallTokens({ isReconnecting: true });

try {
if (!livekitUrl) {
throw new Error("LiveKit URL not available");
}
await room.connect(livekitUrl, callTokens.audioToken);
} catch (error) {
console.error("Reconnection failed:", error);
updateCallTokens({ isReconnecting: false });
}
} else if (state === ConnectionState.Connected && callTokens?.isReconnecting) {
// Successfully reconnected
console.log("Successfully reconnected!");
updateCallTokens({ isReconnecting: false });
}
};

room.on(RoomEvent.ConnectionStateChanged, handleConnectionStateChange);

return () => {
room.off(RoomEvent.ConnectionStateChanged, handleConnectionStateChange);
};
}, [room, callTokens, updateCallTokens, livekitUrl]);
Comment on lines +628 to +659
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reconnection logic has potential issues with stale closures and lacks retry backoff.

Several concerns with the current implementation:

  1. Stale closure: The async handler captures callTokens and livekitUrl from the closure. If state changes during the async room.connect() call, the values used may be stale. For example, if the user ends the call while reconnecting, callTokens in the closure would still be truthy.

  2. No retry delay/backoff: Immediate reconnection without delay could cause rapid retry loops if the server is temporarily unavailable.

  3. Token expiration: After a network issue, callTokens.audioToken might have expired, causing auth failures without any token refresh mechanism.

Proposed fix with stale closure guard and retry delay
   useEffect(() => {
     const handleConnectionStateChange = async (state: ConnectionState) => {
       console.log("Connection state changed:", state);

-      if (state === ConnectionState.Disconnected && callTokens) {
+      // Capture current state for the async operation
+      const currentCallTokens = useStore.getState().callTokens;
+      const currentLivekitUrl = useStore.getState().livekitUrl;
+
+      if (state === ConnectionState.Disconnected && currentCallTokens) {
         // Room disconnected but we still have callTokens - try to reconnect
         console.log("Room disconnected, attempting to reconnect...");
         updateCallTokens({ isReconnecting: true });

         try {
-          if (!livekitUrl) {
+          if (!currentLivekitUrl) {
             throw new Error("LiveKit URL not available");
           }
-          await room.connect(livekitUrl, callTokens.audioToken);
+          // Add a small delay before reconnecting
+          await new Promise((resolve) => setTimeout(resolve, 1000));
+          // Re-check that we still want to reconnect
+          if (!useStore.getState().callTokens) {
+            console.log("Call ended during reconnection delay, aborting");
+            return;
+          }
+          await room.connect(currentLivekitUrl, currentCallTokens.audioToken);
         } catch (error) {
           console.error("Reconnection failed:", error);
           updateCallTokens({ isReconnecting: false });
         }
-      } else if (state === ConnectionState.Connected && callTokens?.isReconnecting) {
+      } else if (state === ConnectionState.Connected && useStore.getState().callTokens?.isReconnecting) {
         // Successfully reconnected
         console.log("Successfully reconnected!");
         updateCallTokens({ isReconnecting: false });
       }
     };

     room.on(RoomEvent.ConnectionStateChanged, handleConnectionStateChange);

     return () => {
       room.off(RoomEvent.ConnectionStateChanged, handleConnectionStateChange);
     };
-  }, [room, callTokens, updateCallTokens, livekitUrl]);
+  }, [room, updateCallTokens]);
🤖 Prompt for AI Agents
In `@tauri/src/components/ui/call-center.tsx` around lines 628 - 659, The
reconnection handler (handleConnectionStateChange) captures stale
callTokens/livekitUrl and retries immediately; change it to read the latest
values at reconnection time (e.g., store callTokens and livekitUrl in refs like
callTokensRef/livekitUrlRef or call a getter to retrieve current state) and
guard before attempting reconnect by re-checking room.state and
callTokensRef.current; implement a retry loop with exponential backoff and a max
attempts counter when calling room.connect, and before each attempt verify token
validity (call an existing token refresh function or add refreshCallTokens() to
obtain a fresh callTokens.audioToken) so room.connect uses a non-expired token;
keep updateCallTokens({ isReconnecting }) usage to reflect state transitions.


useEffect(() => {
if (!callTokens) return;

Expand Down
9 changes: 9 additions & 0 deletions tauri/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export type CallState = {
krispToggle?: boolean;
controllerSupportsAv1?: boolean;
av1Enabled?: boolean;
// Reconnection state
isReconnecting?: boolean;
} & TCallTokensMessage["payload"];

type State = {
Expand All @@ -47,6 +49,7 @@ type State = {
// Call tokens for LiveKit
callTokens: CallState | null;
customServerUrl: string | null;
livekitUrl: string | null;
};

type Actions = {
Expand All @@ -63,6 +66,7 @@ type Actions = {
setCallTokens: (tokens: CallState | null) => void;
updateCallTokens: (tokens: Partial<CallState>) => void;
setCustomServerUrl: (url: string | null) => void;
setLivekitUrl: (url: string | null) => void;
};

const initialState: State = {
Expand All @@ -76,6 +80,7 @@ const initialState: State = {
calling: null,
callTokens: null,
customServerUrl: null,
livekitUrl: null,
};

/**
Expand Down Expand Up @@ -130,6 +135,10 @@ const useStore = create<State & Actions>()(
set((state) => {
state.customServerUrl = url;
}),
setLivekitUrl: (url) =>
set((state) => {
state.livekitUrl = url;
}),
setTab: (tab) =>
set((state) => {
state.tab = tab;
Expand Down
10 changes: 5 additions & 5 deletions tauri/src/windows/main-window/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { listen } from "@tauri-apps/api/event";
import Invite from "./invite";
import { sounds } from "@/constants/sounds";
import { useDisableNativeContextMenu } from "@/lib/hooks";
import { validateAndSetAuthToken } from "@/lib/authUtils";
import { processDeepLinkUrl } from "@/lib/deepLinkUtils";
import { Rooms } from "./tabs/Rooms";
import { LiveKitRoom } from "@livekit/components-react";
Expand All @@ -44,6 +43,7 @@ function App() {
setTab,
setTeammates,
setAuthToken,
setLivekitUrl,
} = useStore();

const coreProcessCrashedRef = useRef(false);
Expand All @@ -52,8 +52,8 @@ function App() {
const { useQuery } = useAPI();

const [incomingCallerId, setIncomingCallerId] = useState<string | null>(null);
const [livekitUrl, setLivekitUrl] = useState<string>("");
const sentryMetadataRef = useRef<boolean>(false);
const livekitUrl = useStore((s) => s.livekitUrl);

const { error: userError } = useQuery("get", "/api/auth/user", undefined, {
enabled: !!authToken,
Expand Down Expand Up @@ -90,7 +90,7 @@ function App() {
queryHash: `livekit-url-${authToken}`,
});

// Send LiveKit URL to Tauri backend when it's fetched
// Send LiveKit URL to Tauri backend and store when fetched
useEffect(() => {
const sendLivekitUrlToBackend = async () => {
if (livekitUrlData?.url) {
Expand All @@ -106,7 +106,7 @@ function App() {
};

sendLivekitUrlToBackend();
}, [livekitUrlData]);
}, [livekitUrlData, setLivekitUrl]);

// Load stored token and custom server URL on app start
useEffect(() => {
Expand Down Expand Up @@ -389,7 +389,7 @@ function App() {
<ConditionalWrap
condition={!!callTokens}
wrap={(children) => (
<LiveKitRoom key={callTokens?.audioToken} token={callTokens?.audioToken} serverUrl={livekitUrl}>
<LiveKitRoom key={callTokens?.audioToken} token={callTokens?.audioToken} serverUrl={livekitUrl || ""}>
{children}
</LiveKitRoom>
)}
Expand Down
Loading