Skip to content

Commit 858a1e9

Browse files
authored
fix: harden websocket client error handling (#41)
1 parent e421504 commit 858a1e9

File tree

2 files changed

+275
-80
lines changed

2 files changed

+275
-80
lines changed
Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,142 @@
1-
import { describe, it, expect } from "vitest";
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import {
3+
CrdtType,
4+
JoinErrorCode,
5+
MessageType,
6+
type JoinError,
7+
} from "loro-protocol";
8+
import * as protocol from "loro-protocol";
9+
import { LoroWebsocketClient } from "./index";
10+
11+
class FakeWebSocket {
12+
static CONNECTING = 0;
13+
static OPEN = 1;
14+
static CLOSING = 2;
15+
static CLOSED = 3;
16+
17+
readyState = FakeWebSocket.CLOSED;
18+
bufferedAmount = 0;
19+
binaryType: any = "arraybuffer";
20+
url: string;
21+
lastSent: unknown;
22+
private listeners = new Map<string, Set<(ev: any) => void>>();
23+
24+
constructor(url: string) {
25+
this.url = url;
26+
}
27+
28+
addEventListener(type: string, listener: (ev: any) => void) {
29+
const set = this.listeners.get(type) ?? new Set();
30+
set.add(listener);
31+
this.listeners.set(type, set);
32+
}
33+
34+
removeEventListener(type: string, listener: (ev: any) => void) {
35+
const set = this.listeners.get(type);
36+
set?.delete(listener);
37+
}
38+
39+
dispatch(type: string, ev: any) {
40+
const set = this.listeners.get(type);
41+
if (!set) return;
42+
for (const l of Array.from(set)) l(ev);
43+
}
44+
45+
send(data: any) {
46+
if (this.readyState !== FakeWebSocket.OPEN) {
47+
throw new Error("WebSocket is not open");
48+
}
49+
this.lastSent = data;
50+
}
51+
52+
close() {
53+
this.readyState = FakeWebSocket.CLOSED;
54+
}
55+
}
256

357
describe("LoroWebsocketClient", () => {
4-
it("is placeholder", () => {
5-
expect(true).toBe(true);
58+
let originalWebSocket: any;
59+
60+
beforeEach(() => {
61+
originalWebSocket = (globalThis as any).WebSocket;
62+
(globalThis as any).WebSocket = FakeWebSocket as any;
63+
});
64+
65+
afterEach(() => {
66+
(globalThis as any).WebSocket = originalWebSocket;
67+
vi.restoreAllMocks();
68+
});
69+
70+
it("does not throw when retrying join after closed socket and reports via onError", async () => {
71+
const onError = vi.fn();
72+
const client = new LoroWebsocketClient({
73+
url: "ws://test",
74+
disablePing: true,
75+
reconnect: { enabled: false },
76+
onError,
77+
});
78+
79+
const adaptor = {
80+
crdtType: CrdtType.Loro,
81+
setCtx: () => { },
82+
getVersion: () => new Uint8Array([0]),
83+
getAlternativeVersion: () => new Uint8Array([1]),
84+
handleJoinOk: async () => { },
85+
waitForReachingServerVersion: async () => { },
86+
destroy: () => { },
87+
} satisfies any;
88+
89+
const joinError: JoinError = {
90+
type: MessageType.JoinError,
91+
code: JoinErrorCode.VersionUnknown,
92+
message: "",
93+
crdt: adaptor.crdtType,
94+
roomId: "room",
95+
};
96+
97+
const pending = {
98+
room: Promise.resolve({} as any),
99+
resolve: () => { },
100+
reject: () => { },
101+
adaptor,
102+
roomId: "room",
103+
} satisfies any;
104+
105+
// Avoid unhandled rejection when the client is destroyed without ever opening.
106+
(client as any).connectedPromise?.catch(() => { });
107+
108+
// Force the current socket to a closed state so send will fail.
109+
(client as any).ws.readyState = FakeWebSocket.CLOSED;
110+
111+
await expect(
112+
(client as any).handleJoinError(joinError, pending, adaptor.crdtType + "room")
113+
).resolves.not.toThrow();
114+
115+
expect(onError).toHaveBeenCalledTimes(1);
116+
expect(((client as any).queuedJoins ?? []).length).toBeGreaterThan(0);
117+
118+
});
119+
120+
it("forwards decode or handler errors to onError instead of crashing", async () => {
121+
const onError = vi.fn();
122+
const client = new LoroWebsocketClient({
123+
url: "ws://test",
124+
disablePing: true,
125+
reconnect: { enabled: false },
126+
onError,
127+
});
128+
129+
(client as any).connectedPromise?.catch(() => { });
130+
131+
vi.spyOn(protocol, "tryDecode").mockImplementation(() => {
132+
throw new Error("decode failed");
133+
});
134+
135+
await (client as any).onSocketMessage((client as any).ws, {
136+
data: new ArrayBuffer(0),
137+
} as MessageEvent<ArrayBuffer>);
138+
139+
expect(onError).toHaveBeenCalledTimes(1);
140+
6141
});
7142
});

0 commit comments

Comments
 (0)