Skip to content

Commit fd1a67c

Browse files
committed
fix: use right status and auto retry after come back online
1 parent 9428a1b commit fd1a67c

3 files changed

Lines changed: 307 additions & 56 deletions

File tree

packages/loro-websocket/README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ interface LoroWebsocketClientOptions {
7777
const ClientStatus = {
7878
Connecting: "connecting",
7979
Connected: "connected",
80-
Reconnecting: "reconnecting",
8180
Disconnected: "disconnected",
8281
} as const;
8382
type ClientStatusValue = typeof ClientStatus[keyof typeof ClientStatus];
@@ -107,13 +106,12 @@ type ClientStatusValue = typeof ClientStatus[keyof typeof ClientStatus];
107106
- Status values
108107
- `Connecting`: initial or manual `connect()` in progress.
109108
- `Connected`: websocket is open and usable.
110-
- `Reconnecting`: unexpected close; client retries with exponential backoff.
111-
- `Disconnected`: manual `close()`; client will not auto‑reconnect until `connect()` is called.
109+
- `Disconnected`: socket closed. Auto‑reconnect keeps retrying unless `close()`/`destroy()` stop it.
112110

113111
- Auto‑reconnect
114-
- Exponential backoff: starts at ~500ms, doubles each attempt, capped at 15s.
115-
- Pauses while browser is offline; resumes immediately on `online` event.
116-
- After a manual `close()`, call `connect()` to resume retries.
112+
- Retry cadence: attempts start ~500 ms after the first unexpected close and double on each failure (1 s, 2 s, 4 s, …) up to a 15 s ceiling. A successful connection resets the backoff to the 500 ms starting point.
113+
- The timer is canceled when the environment reports `offline`; the client stays in `Disconnected` until an `online` event arrives, at which point the next retry fires immediately.
114+
- Calling `close()` or `destroy()` turns off auto‑retry. Invoke `connect()` later to restart the process with a fresh backoff window.
117115

118116
## Latency & Ping/Pong
119117

packages/loro-websocket/src/client/index.ts

Lines changed: 134 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,38 @@ interface SocketListeners {
5353
message: (event: MessageEvent<string | ArrayBuffer>) => void;
5454
}
5555

56+
type NodeProcessLike = {
57+
on?: (event: string, listener: () => void) => unknown;
58+
off?: (event: string, listener: () => void) => unknown;
59+
removeListener?: (event: string, listener: () => void) => unknown;
60+
};
61+
62+
const readInitialOnlineState = (): boolean => {
63+
try {
64+
if (typeof navigator !== "undefined") {
65+
const navOnline = (navigator as { onLine?: unknown }).onLine;
66+
if (typeof navOnline === "boolean") {
67+
return navOnline;
68+
}
69+
}
70+
} catch {}
71+
72+
const globalScope = globalThis as {
73+
navigator?: { onLine?: unknown };
74+
};
75+
const maybe = globalScope.navigator?.onLine;
76+
return typeof maybe === "boolean" ? maybe : true;
77+
};
78+
5679
/**
5780
* The websocket client's high-level connection status.
5881
* - `Connecting`: initial connect or a manual `connect()` in progress.
5982
* - `Connected`: the websocket is open and usable.
60-
* - `Reconnecting`: the connection dropped unexpectedly; the client is retrying with exponential backoff.
61-
* - `Disconnected`: the client is not connected and won't auto-reconnect (call `connect()` to resume).
83+
* - `Disconnected`: the client is not connected. Call `connect()` to retry.
6284
*/
6385
export const ClientStatus = {
6486
Connecting: "connecting",
6587
Connected: "connected",
66-
Reconnecting: "reconnecting",
6788
Disconnected: "disconnected",
6889
} as const;
6990
export type ClientStatusValue =
@@ -93,8 +114,7 @@ export interface LoroWebsocketClientOptions {
93114
*
94115
* Status model:
95116
* - `Connected`: ws open.
96-
* - `Reconnecting`: closed unexpectedly; client retries with exponential backoff. Pauses while offline; resumes on `online`.
97-
* - `Disconnected`: closed via `close()`; client will not reconnect until `connect()` is called again.
117+
* - `Disconnected`: socket closed. Auto-reconnect retries run unless `close()`/`destroy()` stop them.
98118
* - `Connecting`: initial or manual connect in progress.
99119
*
100120
* Events:
@@ -134,28 +154,91 @@ export class LoroWebsocketClient {
134154
private shouldReconnect = true;
135155
private reconnectAttempts = 0;
136156
private reconnectTimer?: ReturnType<typeof setTimeout>;
137-
private isOnline = true;
157+
private isOnline = readInitialOnlineState();
158+
private removeNetworkListeners?: () => void;
138159

139160
constructor(private ops: LoroWebsocketClientOptions) {
140-
// Wire browser network events if available
161+
this.attachNetworkListeners();
162+
163+
// Start initial connection
164+
this.ensureConnectedPromise();
165+
void this.connect();
166+
}
167+
168+
private ensureConnectedPromise(): void {
169+
if (this.resolveConnected) return;
170+
this.connectedPromise = new Promise<void>((resolve, reject) => {
171+
this.resolveConnected = () => {
172+
this.resolveConnected = undefined;
173+
this.rejectConnected = undefined;
174+
resolve();
175+
};
176+
this.rejectConnected = (err: Error) => {
177+
this.resolveConnected = undefined;
178+
this.rejectConnected = undefined;
179+
reject(err);
180+
};
181+
});
182+
}
183+
184+
private attachNetworkListeners(): void {
185+
this.removeNetworkListeners?.();
186+
this.removeNetworkListeners = undefined;
187+
141188
if (
142189
typeof window !== "undefined" &&
143190
typeof window.addEventListener === "function"
144191
) {
145192
window.addEventListener("online", this.handleOnline);
146193
window.addEventListener("offline", this.handleOffline);
194+
this.removeNetworkListeners = () => {
195+
window.removeEventListener("online", this.handleOnline);
196+
window.removeEventListener("offline", this.handleOffline);
197+
};
198+
return;
147199
}
148200

149-
// Start initial connection
150-
this.connectedPromise = this.createConnectedPromise();
151-
void this.connect();
152-
}
201+
const globalScope = globalThis as typeof globalThis & {
202+
addEventListener?: (
203+
type: string,
204+
listener: EventListenerOrEventListenerObject
205+
) => void;
206+
removeEventListener?: (
207+
type: string,
208+
listener: EventListenerOrEventListenerObject
209+
) => void;
210+
process?: NodeProcessLike;
211+
};
153212

154-
private createConnectedPromise() {
155-
return new Promise<void>((resolve, reject) => {
156-
this.resolveConnected = resolve;
157-
this.rejectConnected = reject;
158-
});
213+
if (typeof globalScope.addEventListener === "function") {
214+
const online = this.handleOnline as EventListener;
215+
const offline = this.handleOffline as EventListener;
216+
globalScope.addEventListener("online", online);
217+
globalScope.addEventListener("offline", offline);
218+
this.removeNetworkListeners = () => {
219+
globalScope.removeEventListener?.("online", online);
220+
globalScope.removeEventListener?.("offline", offline);
221+
};
222+
return;
223+
}
224+
225+
const maybeProcess = globalScope.process;
226+
if (maybeProcess && typeof maybeProcess.on === "function") {
227+
// Node environments may surface online/offline via the global process emitter.
228+
const online = () => this.handleOnline();
229+
const offline = () => this.handleOffline();
230+
maybeProcess.on("online", online);
231+
maybeProcess.on("offline", offline);
232+
this.removeNetworkListeners = () => {
233+
if (typeof maybeProcess.off === "function") {
234+
maybeProcess.off("online", online);
235+
maybeProcess.off("offline", offline);
236+
} else if (typeof maybeProcess.removeListener === "function") {
237+
maybeProcess.removeListener("online", online);
238+
maybeProcess.removeListener("offline", offline);
239+
}
240+
};
241+
}
159242
}
160243

161244
/** Current client status. */
@@ -212,14 +295,15 @@ export class LoroWebsocketClient {
212295
}
213296
}
214297
this.clearReconnectTimer();
215-
this.setStatus(
216-
this.reconnectAttempts > 0
217-
? ClientStatus.Reconnecting
218-
: ClientStatus.Connecting
219-
);
298+
// Ensure there's a pending promise for this attempt
299+
this.ensureConnectedPromise();
300+
301+
if (!this.isOnline) {
302+
this.setStatus(ClientStatus.Disconnected);
303+
return this.connectedPromise;
304+
}
220305

221-
// Reset the connected promise for this attempt
222-
this.connectedPromise = this.createConnectedPromise();
306+
this.setStatus(ClientStatus.Connecting);
223307

224308
const ws = new WebSocket(this.ops.url);
225309
this.ws = ws;
@@ -316,7 +400,7 @@ export class LoroWebsocketClient {
316400
return;
317401
}
318402
// Start (or continue) exponential backoff retries
319-
this.setStatus(ClientStatus.Reconnecting);
403+
this.setStatus(ClientStatus.Disconnected);
320404
this.scheduleReconnect();
321405
}
322406

@@ -345,13 +429,13 @@ export class LoroWebsocketClient {
345429
if (msg != null) await this.handleMessage(msg);
346430
}
347431

348-
private scheduleReconnect() {
432+
private scheduleReconnect(immediate = false) {
349433
if (this.reconnectTimer) return;
350434
if (!this.isOnline) return; // pause while offline
351435
const attempt = ++this.reconnectAttempts;
352436
const base = 500; // ms
353437
const max = 15_000; // ms
354-
const delay = Math.min(max, base * 2 ** (attempt - 1));
438+
const delay = immediate ? 0 : Math.min(max, base * 2 ** (attempt - 1));
355439
this.reconnectTimer = setTimeout(() => {
356440
this.reconnectTimer = undefined;
357441
void this.connect();
@@ -365,23 +449,21 @@ export class LoroWebsocketClient {
365449

366450
private handleOnline = () => {
367451
this.isOnline = true;
368-
if (
369-
this.shouldReconnect &&
370-
(this.status === ClientStatus.Reconnecting ||
371-
this.status === ClientStatus.Connecting)
372-
) {
373-
this.clearReconnectTimer();
374-
// Try immediately when back online
375-
void this.connect();
376-
}
452+
if (!this.shouldReconnect) return;
453+
if (this.status === ClientStatus.Connected) return;
454+
this.clearReconnectTimer();
455+
this.scheduleReconnect(true);
377456
};
378457

379458
private handleOffline = () => {
380459
this.isOnline = false;
381460
// Pause scheduled retries until online
382461
this.clearReconnectTimer();
383462
if (this.shouldReconnect) {
384-
this.setStatus(ClientStatus.Reconnecting);
463+
this.setStatus(ClientStatus.Disconnected);
464+
try {
465+
this.ws?.close(1001, "Offline");
466+
} catch {}
385467
}
386468
};
387469

@@ -824,6 +906,7 @@ export class LoroWebsocketClient {
824906
this.shouldReconnect = false;
825907
this.clearReconnectTimer();
826908
this.clearPingTimer();
909+
this.reconnectAttempts = 0;
827910
this.rejectConnected?.(new Error("Disconnected"));
828911
this.rejectConnected = undefined;
829912
this.resolveConnected = undefined;
@@ -855,6 +938,10 @@ export class LoroWebsocketClient {
855938
roomId: string,
856939
update: Uint8Array
857940
): void {
941+
const ws = this.ws;
942+
if (!ws || ws.readyState !== WebSocket.OPEN) {
943+
return;
944+
}
858945
// Leave headroom for protocol overhead to stay under MAX_MESSAGE_SIZE
859946
const FRAG_LIMIT = Math.max(
860947
1,
@@ -863,7 +950,7 @@ export class LoroWebsocketClient {
863950

864951
if (update.length <= FRAG_LIMIT) {
865952
// Send as a single DocUpdate with one update entry
866-
this.ws.send(
953+
ws.send(
867954
encode({
868955
type: MessageType.DocUpdate,
869956
crdt,
@@ -888,7 +975,7 @@ export class LoroWebsocketClient {
888975
fragmentCount,
889976
totalSizeBytes: update.length,
890977
};
891-
this.ws.send(encode(header));
978+
ws.send(encode(header));
892979

893980
for (let i = 0; i < fragmentCount; i++) {
894981
const start = i * FRAG_LIMIT;
@@ -902,7 +989,7 @@ export class LoroWebsocketClient {
902989
index: i,
903990
fragment,
904991
};
905-
this.ws.send(encode(msg));
992+
ws.send(encode(msg));
906993
}
907994
}
908995

@@ -926,6 +1013,7 @@ export class LoroWebsocketClient {
9261013
this.shouldReconnect = false;
9271014
this.clearReconnectTimer();
9281015
this.clearPingTimer();
1016+
this.reconnectAttempts = 0;
9291017
this.rejectConnected?.(new Error("Destroyed"));
9301018
this.rejectConnected = undefined;
9311019
this.resolveConnected = undefined;
@@ -944,16 +1032,10 @@ export class LoroWebsocketClient {
9441032
this.ops.onWsClose?.();
9451033
}
9461034
this.detachSocketListeners(ws);
947-
// Remove window event listeners if present
9481035
try {
949-
if (
950-
typeof window !== "undefined" &&
951-
typeof window.removeEventListener === "function"
952-
) {
953-
window.removeEventListener("online", this.handleOnline);
954-
window.removeEventListener("offline", this.handleOffline);
955-
}
1036+
this.removeNetworkListeners?.();
9561037
} catch {}
1038+
this.removeNetworkListeners = undefined;
9571039
// Close websocket after flushing pending frames
9581040
try {
9591041
this.flushAndCloseWebSocket(ws, {
@@ -997,7 +1079,11 @@ export class LoroWebsocketClient {
9971079
}
9981080

9991081
const buffered = readBufferedAmount();
1000-
if (buffered == null || buffered <= 0 || Date.now() - start >= timeoutMs) {
1082+
if (
1083+
buffered == null ||
1084+
buffered <= 0 ||
1085+
Date.now() - start >= timeoutMs
1086+
) {
10011087
requested = true;
10021088
try {
10031089
ws.close(code, reason);

0 commit comments

Comments
 (0)