Skip to content

Commit 24f7c83

Browse files
SanjanaTech19unknown
andauthored
fix(socket): prevent duplicate connections on rapid offline-online toggles (fixes #1390) (#1410)
* fix(socket): prevent duplicate connections on rapid offline-online toggles (fixes #1390) * fix(chat): resolve upstream merge conflict markers and tts lint warnings --------- Co-authored-by: unknown <your-email@example.com>
1 parent 5a1f067 commit 24f7c83

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

‎src/__tests__/lib/partySocketReconnect.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,83 @@ describe("attachJitteredBackoff", () => {
5858
attachJitteredBackoff(socket);
5959
expect(socket._getNextDelay).toBe(first);
6060
});
61+
62+
it("guards connection attempts using ConnectionState enum", () => {
63+
const mockConnect = jest.fn();
64+
const mockDisconnect = jest.fn();
65+
const eventListeners: Record<string, Array<() => void>> = {};
66+
67+
const socket = {
68+
_retryCount: 0,
69+
_getNextDelay: () => 10,
70+
_connect: mockConnect,
71+
_disconnect: mockDisconnect,
72+
_clearTimeouts: jest.fn(),
73+
addEventListener: (event: string, cb: () => void) => {
74+
if (!eventListeners[event]) eventListeners[event] = [];
75+
eventListeners[event].push(cb);
76+
},
77+
} as any;
78+
79+
attachJitteredBackoff(socket);
80+
81+
// Initial state is CLOSED
82+
expect(socket.__worksphereState).toBe("CLOSED");
83+
84+
// Calling connect transitions state to CONNECTING
85+
socket._connect();
86+
expect(socket.__worksphereState).toBe("CONNECTING");
87+
expect(mockConnect).toHaveBeenCalledTimes(1);
88+
89+
// Calling connect again while CONNECTING does not trigger mockConnect again
90+
socket._connect();
91+
expect(mockConnect).toHaveBeenCalledTimes(1);
92+
93+
// Simulate open event transitions to CONNECTED
94+
eventListeners["open"]?.forEach((cb) => cb());
95+
expect(socket.__worksphereState).toBe("CONNECTED");
96+
97+
// Calling connect while CONNECTED does not trigger mockConnect
98+
socket._connect();
99+
expect(mockConnect).toHaveBeenCalledTimes(1);
100+
101+
// Calling disconnect transitions state to CLOSED
102+
socket._disconnect();
103+
expect(socket.__worksphereState).toBe("CLOSED");
104+
expect(mockDisconnect).toHaveBeenCalledTimes(1);
105+
});
106+
107+
it("aborts pending reconnect timers on new connection or disconnect", async () => {
108+
jest.useFakeTimers();
109+
110+
const socket = {
111+
_retryCount: 1,
112+
_getNextDelay: () => 1000,
113+
_connect: jest.fn(),
114+
_disconnect: jest.fn(),
115+
_clearTimeouts: jest.fn(),
116+
addEventListener: jest.fn(),
117+
} as any;
118+
119+
attachJitteredBackoff(socket);
120+
121+
// Start waiting for reconnect
122+
const _waitPromise = socket._wait();
123+
124+
// Reconnect timer is scheduled in setTimeout. Let's call disconnect before it fires.
125+
socket._disconnect();
126+
127+
// Fast-forward time
128+
jest.advanceTimersByTime(1000);
129+
130+
// Wait for any microtasks
131+
await Promise.resolve();
132+
133+
// Verify waitPromise did not resolve yet because the timeout was cleared by _disconnect
134+
// Since waitPromise resolves inside setTimeout which was cleared, it remains pending.
135+
// If we trigger _connect, it also clears any pending timeouts.
136+
jest.useRealTimers();
137+
});
61138
});
62139

63140
import { PartySocketReconnectManager } from "@/lib/partySocketReconnect";

‎src/lib/partySocketReconnect.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,106 @@ export function jitteredReconnectDelay(
3939
return Math.round(Math.min(max, Math.max(min, base + jitter)));
4040
}
4141

42+
export enum ConnectionState {
43+
CLOSED = "CLOSED",
44+
CONNECTING = "CONNECTING",
45+
CONNECTED = "CONNECTED",
46+
}
47+
4248
type DelaySocket = {
4349
_retryCount: number;
4450
_getNextDelay: () => number;
51+
_connect?: () => void;
52+
_disconnect?: (code?: number, reason?: string) => void;
53+
_clearTimeouts?: () => void;
54+
_wait?: () => Promise<void>;
55+
addEventListener?: (
56+
event: string,
57+
callback: (...args: any[]) => void,
58+
) => void;
4559
__worksphereJitter?: boolean;
60+
__worksphereState?: ConnectionState;
4661
};
4762

4863
/** Swap in jittered backoff on a live PartySocket instance (idempotent). */
4964
export function attachJitteredBackoff<T extends object>(socket: T): T {
5065
const s = socket as T & DelaySocket;
5166
if (s.__worksphereJitter) return socket;
5267

68+
let pendingTimeoutId: any = null;
69+
s.__worksphereState = ConnectionState.CLOSED;
70+
5371
s._getNextDelay = function (this: DelaySocket) {
5472
return jitteredReconnectDelay(this._retryCount);
5573
};
74+
75+
s._wait = function (this: any) {
76+
if (pendingTimeoutId) {
77+
clearTimeout(pendingTimeoutId);
78+
}
79+
return new Promise<void>((resolve) => {
80+
pendingTimeoutId = setTimeout(() => {
81+
pendingTimeoutId = null;
82+
resolve();
83+
}, this._getNextDelay());
84+
});
85+
};
86+
87+
const originalClearTimeouts = s._clearTimeouts;
88+
s._clearTimeouts = function (this: any) {
89+
if (pendingTimeoutId) {
90+
clearTimeout(pendingTimeoutId);
91+
pendingTimeoutId = null;
92+
}
93+
if (originalClearTimeouts) {
94+
originalClearTimeouts.call(this);
95+
}
96+
};
97+
98+
const originalDisconnect = s._disconnect;
99+
s._disconnect = function (this: any, code?: number, reason?: string) {
100+
s.__worksphereState = ConnectionState.CLOSED;
101+
if (pendingTimeoutId) {
102+
clearTimeout(pendingTimeoutId);
103+
pendingTimeoutId = null;
104+
}
105+
if (originalDisconnect) {
106+
originalDisconnect.call(this, code, reason);
107+
}
108+
};
109+
110+
const originalConnect = s._connect;
111+
if (originalConnect) {
112+
s._connect = function (this: any) {
113+
if (
114+
s.__worksphereState === ConnectionState.CONNECTING ||
115+
s.__worksphereState === ConnectionState.CONNECTED
116+
) {
117+
return;
118+
}
119+
s.__worksphereState = ConnectionState.CONNECTING;
120+
if (pendingTimeoutId) {
121+
clearTimeout(pendingTimeoutId);
122+
pendingTimeoutId = null;
123+
}
124+
originalConnect.call(this);
125+
};
126+
}
127+
128+
if (typeof s.addEventListener === "function") {
129+
s.addEventListener("open", () => {
130+
s.__worksphereState = ConnectionState.CONNECTED;
131+
});
132+
133+
s.addEventListener("close", () => {
134+
s.__worksphereState = ConnectionState.CLOSED;
135+
});
136+
137+
s.addEventListener("error", () => {
138+
s.__worksphereState = ConnectionState.CLOSED;
139+
});
140+
}
141+
56142
s.__worksphereJitter = true;
57143
return socket;
58144
}

0 commit comments

Comments
 (0)