Skip to content

Commit 3f6d2f6

Browse files
committed
always start a new websocket if existing is disconnected
1 parent 82b46c5 commit 3f6d2f6

File tree

2 files changed

+201
-32
lines changed

2 files changed

+201
-32
lines changed

packages/wallet-sdk/src/relay/walletlink/connection/WalletLinkConnection.ts

Lines changed: 172 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export class WalletLinkConnection {
4343
private nextReqId = IntNumber(1);
4444
private heartbeatIntervalId?: number;
4545
private reconnectAttempts = 0;
46+
private visibilityChangeHandler?: () => void;
47+
private focusHandler?: () => void;
48+
private activeWsInstance?: WalletLinkWebSocket;
49+
private isReconnecting = false;
4650

4751
private readonly session: Session;
4852

@@ -51,6 +55,8 @@ export class WalletLinkConnection {
5155
private cipher: Cipher;
5256
private ws: WalletLinkWebSocket;
5357
private http: WalletLinkHTTP;
58+
private readonly linkAPIUrl: string;
59+
private readonly WebSocketClass: typeof WebSocket;
5460

5561
/**
5662
* Constructor
@@ -70,16 +76,38 @@ export class WalletLinkConnection {
7076
this.cipher = new Cipher(session.secret);
7177
this.diagnostic = diagnostic;
7278
this.listener = listener;
79+
this.linkAPIUrl = linkAPIUrl;
80+
this.WebSocketClass = WebSocketClass;
7381

7482
console.debug('[WalletLinkConnection] Creating new WalletLinkWebSocket instance');
75-
const ws = new WalletLinkWebSocket(`${linkAPIUrl}/rpc`, WebSocketClass);
83+
const ws = this.createWebSocket();
84+
this.ws = ws;
85+
86+
this.http = new WalletLinkHTTP(linkAPIUrl, session.id, session.key);
87+
88+
// Set up visibility and focus handlers for mobile Safari
89+
this.setupMobileSafariHandlers();
90+
}
91+
92+
private createWebSocket(): WalletLinkWebSocket {
93+
const ws = new WalletLinkWebSocket(`${this.linkAPIUrl}/rpc`, this.WebSocketClass);
7694
console.debug('[WalletLinkConnection] WebSocket instance created. Total active WebSocket instances:', (WalletLinkWebSocket as any).getActiveInstances());
95+
96+
// Track this as the active WebSocket instance
97+
this.activeWsInstance = ws;
98+
7799
ws.setConnectionStateListener(async (state) => {
100+
// Ignore events from non-active WebSocket instances
101+
if (ws !== this.activeWsInstance) {
102+
console.debug('[WalletLinkConnection] Ignoring state change from non-active WebSocket instance');
103+
return;
104+
}
105+
78106
// attempt to reconnect every 5 seconds when disconnected
79107
console.debug('[WalletLinkConnection] Connection state changed to:', ConnectionState[state], 'shouldFetchUnseenEventsOnConnect:', this.shouldFetchUnseenEventsOnConnect);
80108
this.diagnostic?.log(EVENTS.CONNECTED_STATE_CHANGE, {
81109
state,
82-
sessionIdHash: Session.hash(session.id),
110+
sessionIdHash: Session.hash(this.session.id),
83111
});
84112

85113
let connected = false;
@@ -92,26 +120,55 @@ export class WalletLinkConnection {
92120
console.debug('[WalletLinkConnection] Cleared heartbeat timer');
93121
}
94122

123+
// Reset lastHeartbeatResponse to prevent false timeout on reconnection
124+
this.lastHeartbeatResponse = 0;
125+
console.debug('[WalletLinkConnection] Reset lastHeartbeatResponse on disconnect');
126+
95127
// Reset connected state to false on disconnect
96128
connected = false;
97129
console.debug('[WalletLinkConnection] DISCONNECTED case - shouldFetchUnseenEventsOnConnect:', this.shouldFetchUnseenEventsOnConnect);
98130

99-
// if DISCONNECTED and not destroyed
131+
// if DISCONNECTED and not destroyed, create a fresh WebSocket connection
100132
if (!this.destroyed) {
101-
const connect = async () => {
133+
const reconnect = async () => {
134+
// Prevent multiple concurrent reconnection attempts
135+
if (this.isReconnecting) {
136+
console.debug('[WalletLinkConnection] Reconnection already in progress, skipping');
137+
return;
138+
}
139+
140+
this.isReconnecting = true;
141+
142+
// Calculate delay with exponential backoff (max 30 seconds)
143+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
144+
console.debug(`[WalletLinkConnection] Waiting ${delay}ms before reconnection attempt ${this.reconnectAttempts + 1}`);
145+
102146
// wait with exponential backoff
103147
await new Promise((resolve) => setTimeout(resolve, delay));
104-
// check whether it's destroyed again
105-
if (!this.destroyed && state === ConnectionState.DISCONNECTED) {
106-
// reconnect
148+
149+
// check whether it's destroyed again and ensure this is still the active instance
150+
if (!this.destroyed && ws === this.activeWsInstance) {
107151
this.reconnectAttempts++;
108-
console.debug('[WalletLinkConnection] Attempting to reconnect');
109-
ws.connect().catch(() => {
110-
connect();
152+
console.debug('[WalletLinkConnection] Creating fresh WebSocket for reconnection');
153+
154+
// Clean up the old WebSocket instance
155+
if ('cleanup' in this.ws && typeof this.ws.cleanup === 'function') {
156+
(this.ws as any).cleanup();
157+
}
158+
159+
// Create a fresh WebSocket instance
160+
this.ws = this.createWebSocket();
161+
this.ws.connect().catch(() => {
162+
console.error('[WalletLinkConnection] Reconnection failed, will retry');
163+
}).finally(() => {
164+
this.isReconnecting = false;
111165
});
166+
} else {
167+
console.debug('[WalletLinkConnection] Skipping reconnection - destroyed or not active instance');
168+
this.isReconnecting = false;
112169
}
113170
};
114-
connect();
171+
reconnect();
115172
}
116173
break;
117174

@@ -144,8 +201,15 @@ export class WalletLinkConnection {
144201
});
145202
} catch (error) {
146203
console.error('[WalletLinkConnection] Authentication failed:', error);
204+
// Don't set connected to true if authentication fails
205+
break;
147206
}
148207

208+
// Update connected state immediately after successful authentication
209+
// This ensures heartbeats won't be skipped
210+
console.debug('[WalletLinkConnection] Setting connected state to true before starting heartbeat');
211+
this.connected = connected;
212+
149213
// send heartbeat every n seconds while connected
150214
// if CONNECTED, start the heartbeat timer
151215
// first timer event updates lastHeartbeat timestamp
@@ -162,17 +226,28 @@ export class WalletLinkConnection {
162226
}, HEARTBEAT_INTERVAL);
163227
console.debug('[WalletLinkConnection] Started heartbeat timer');
164228

229+
// Send an immediate heartbeat to verify connection is alive
230+
// This is especially important for reconnections
231+
setTimeout(() => {
232+
console.debug('[WalletLinkConnection] Sending initial heartbeat after connection');
233+
this.heartbeat();
234+
}, 100);
235+
165236
break;
166237

167238
case ConnectionState.CONNECTING:
168239
console.debug('[WalletLinkConnection] Connection in progress');
169240
break;
170241
}
171242

172-
// Always update connected state to ensure proper transitions
173-
console.debug('[WalletLinkConnection] Updating connected state from', this.connected, 'to', connected);
174-
this.connected = connected;
243+
// Update connected state for DISCONNECTED and CONNECTING cases
244+
// For CONNECTED case, it's already set above
245+
if (state !== ConnectionState.CONNECTED) {
246+
console.debug('[WalletLinkConnection] Updating connected state from', this.connected, 'to', connected);
247+
this.connected = connected;
248+
}
175249
});
250+
176251
ws.setIncomingDataListener((m) => {
177252
console.debug('[WalletLinkConnection] Received message type:', m.type, 'Full message:', m);
178253
switch (m.type) {
@@ -187,7 +262,7 @@ export class WalletLinkConnection {
187262
const linked = m.type === 'IsLinkedOK' ? m.linked : undefined;
188263
console.debug('[WalletLinkConnection] Link status update:', { linked, onlineGuests: m.onlineGuests });
189264
this.diagnostic?.log(EVENTS.LINKED, {
190-
sessionIdHash: Session.hash(session.id),
265+
sessionIdHash: Session.hash(this.session.id),
191266
linked,
192267
type: m.type,
193268
onlineGuests: m.onlineGuests,
@@ -202,7 +277,7 @@ export class WalletLinkConnection {
202277
case 'SessionConfigUpdated': {
203278
console.debug('[WalletLinkConnection] Session config received:', m.metadata);
204279
this.diagnostic?.log(EVENTS.SESSION_CONFIG_RECEIVED, {
205-
sessionIdHash: Session.hash(session.id),
280+
sessionIdHash: Session.hash(this.session.id),
206281
metadata_keys: m && m.metadata ? Object.keys(m.metadata) : undefined,
207282
});
208283
this.handleSessionMetadataUpdated(m.metadata);
@@ -233,9 +308,77 @@ export class WalletLinkConnection {
233308
this.requestResolutions.get(m.id)?.(m);
234309
}
235310
});
236-
this.ws = ws;
311+
312+
return ws;
313+
}
237314

238-
this.http = new WalletLinkHTTP(linkAPIUrl, session.id, session.key);
315+
private setupMobileSafariHandlers(): void {
316+
// Handle visibility changes (when app is backgrounded/foregrounded)
317+
this.visibilityChangeHandler = () => {
318+
console.debug('[WalletLinkConnection] Visibility changed, document.hidden:', document.hidden);
319+
320+
if (!document.hidden && !this.destroyed) {
321+
// Page became visible - check connection and reconnect if needed
322+
console.debug('[WalletLinkConnection] Page became visible, checking connection status');
323+
324+
// Force a fresh connection if we're disconnected
325+
if (!this.connected) {
326+
console.debug('[WalletLinkConnection] Not connected, forcing fresh connection');
327+
this.reconnectWithFreshWebSocket();
328+
} else {
329+
// Send a heartbeat to check if connection is still alive
330+
this.heartbeat();
331+
}
332+
}
333+
};
334+
335+
// Handle focus events (when user switches back to the tab/app)
336+
this.focusHandler = () => {
337+
console.debug('[WalletLinkConnection] Window focused');
338+
339+
if (!this.destroyed && !this.connected) {
340+
console.debug('[WalletLinkConnection] Window focused but not connected, forcing fresh connection');
341+
this.reconnectWithFreshWebSocket();
342+
}
343+
};
344+
345+
// Add event listeners
346+
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
347+
window.addEventListener('focus', this.focusHandler);
348+
349+
// Also handle pageshow event for iOS Safari
350+
window.addEventListener('pageshow', (event) => {
351+
if (event.persisted) {
352+
console.debug('[WalletLinkConnection] Page restored from bfcache');
353+
if (this.focusHandler) {
354+
this.focusHandler();
355+
}
356+
}
357+
});
358+
}
359+
360+
private reconnectWithFreshWebSocket(): void {
361+
if (this.destroyed) return;
362+
363+
console.debug('[WalletLinkConnection] Reconnecting with fresh WebSocket');
364+
365+
// Clear the active instance reference before disconnecting
366+
const oldWs = this.ws;
367+
this.activeWsInstance = undefined;
368+
369+
// Disconnect current WebSocket
370+
oldWs.disconnect();
371+
372+
// Clean up the old instance
373+
if ('cleanup' in oldWs && typeof oldWs.cleanup === 'function') {
374+
(oldWs as any).cleanup();
375+
}
376+
377+
// Create and connect fresh WebSocket
378+
this.ws = this.createWebSocket();
379+
this.ws.connect().catch((error) => {
380+
console.error('[WalletLinkConnection] Fresh reconnection failed:', error);
381+
});
239382
}
240383

241384
/**
@@ -257,6 +400,9 @@ export class WalletLinkConnection {
257400
*/
258401
public destroy(): void {
259402
this.destroyed = true;
403+
404+
// Clear the active instance reference
405+
this.activeWsInstance = undefined;
260406

261407
// Clear heartbeat timer
262408
if (this.heartbeatIntervalId) {
@@ -265,6 +411,14 @@ export class WalletLinkConnection {
265411
console.debug('[WalletLinkConnection] Cleared heartbeat timer on destroy');
266412
}
267413

414+
// Remove event listeners
415+
if (this.visibilityChangeHandler) {
416+
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
417+
}
418+
if (this.focusHandler) {
419+
window.removeEventListener('focus', this.focusHandler);
420+
}
421+
268422
console.debug('[WalletLinkConnection] Destroying connection - calling disconnect');
269423
this.ws.disconnect();
270424

packages/wallet-sdk/src/relay/walletlink/connection/WalletLinkWebSocket.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export class WalletLinkWebSocket {
1717
private readonly url: string;
1818
private webSocket: WebSocket | null = null;
1919
private pendingData: string[] = [];
20+
private isDisconnecting = false;
2021

2122
private connectionStateListener?: (_: ConnectionState) => void;
2223
setConnectionStateListener(listener: (_: ConnectionState) => void): void {
@@ -53,6 +54,9 @@ export class WalletLinkWebSocket {
5354
if (this.webSocket) {
5455
throw new Error('webSocket object is not null');
5556
}
57+
if (this.isDisconnecting) {
58+
throw new Error('WebSocket is disconnecting, cannot reconnect on same instance');
59+
}
5660
console.debug(`[WalletLinkWebSocket] Instance #${this.instanceId} starting connection attempt to: ${this.url}. Active instances: ${WalletLinkWebSocket.activeInstances.size}`);
5761
return new Promise<void>((resolve, reject) => {
5862
let webSocket: WebSocket;
@@ -130,13 +134,21 @@ export class WalletLinkWebSocket {
130134
console.debug(`[WalletLinkWebSocket] Instance #${this.instanceId} disconnect called but no active connection`);
131135
return;
132136
}
137+
138+
// Mark as disconnecting to prevent reconnection attempts on this instance
139+
this.isDisconnecting = true;
140+
133141
console.debug(`[WalletLinkWebSocket] Instance #${this.instanceId} disconnecting. Active instances before disconnect: ${WalletLinkWebSocket.activeInstances.size}`);
134142
this.clearWebSocket();
135143

136-
this.connectionStateListener?.(ConnectionState.DISCONNECTED);
137-
console.debug(`[WalletLinkWebSocket] Instance #${this.instanceId} state changed to: DISCONNECTED`);
144+
// Clear listeners to prevent memory leaks
145+
const tempListener = this.connectionStateListener;
138146
this.connectionStateListener = undefined;
139147
this.incomingDataListener = undefined;
148+
149+
// Call the listener one last time with DISCONNECTED state
150+
tempListener?.(ConnectionState.DISCONNECTED);
151+
console.debug(`[WalletLinkWebSocket] Instance #${this.instanceId} state changed to: DISCONNECTED`);
140152

141153
try {
142154
webSocket.close();
@@ -155,7 +167,10 @@ export class WalletLinkWebSocket {
155167
if (!webSocket) {
156168
console.debug(`[WalletLinkWebSocket] Instance #${this.instanceId} no active connection, queuing data:`, data);
157169
this.pendingData.push(data);
158-
this.connect();
170+
// Don't auto-connect if we're disconnecting
171+
if (!this.isDisconnecting) {
172+
this.connect();
173+
}
159174
return;
160175
}
161176

@@ -170,18 +185,18 @@ export class WalletLinkWebSocket {
170185
webSocket.send(data);
171186
}
172187

173-
private clearWebSocket(): void {
174-
const { webSocket } = this;
175-
if (!webSocket) {
176-
return;
177-
}
178-
console.debug(`[WalletLinkWebSocket] Instance #${this.instanceId} clearing event handlers`);
179-
this.webSocket = null;
180-
webSocket.onclose = null;
181-
webSocket.onerror = null;
182-
webSocket.onmessage = null;
183-
webSocket.onopen = null;
188+
private clearWebSocket(): void {
189+
const { webSocket } = this;
190+
if (!webSocket) {
191+
return;
184192
}
193+
console.debug(`[WalletLinkWebSocket] Instance #${this.instanceId} clearing event handlers`);
194+
this.webSocket = null;
195+
webSocket.onclose = null;
196+
webSocket.onerror = null;
197+
webSocket.onmessage = null;
198+
webSocket.onopen = null;
199+
}
185200

186201
public static getActiveInstances(): number {
187202
return WalletLinkWebSocket.activeInstances.size;

0 commit comments

Comments
 (0)