Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 47dbd93

Browse files
authored
fix(client): reject calls when websocket is not open instead of sending into a closed socket (#1581)
## Problem `LinkWebsocketClient` calls `websocket.send()` without checking the socket state. With a plain WebSocket this doesn't matter, but with reconnecting sockets like partysocket (which the docs recommend) it breaks in two ways: - Frames sent while disconnected are buffered and replayed onto the next connection. By then the client peer has already rejected those calls and forgotten their ids (the close handler calls `peer.close()`), so the server ends up with ghost requests, e.g. duplicate event-iterator subscriptions streaming everything twice (or N times), and mutations that get re-executed. - With `maxEnqueuedMessages: 0` on partysocket's side: the frame is silently dropped, but the peer still awaits a response forever. The call never settles, so caller retry logic never runs. Easy to reproduce: subscribe to an event iterator, kill the server for ~20s while the client retries, bring it back. Each retry leaves one buffered REQUEST frame, and on reconnect they all become live server-side subscriptions. ## Fix Each send now dispatches on `readyState`: - **`OPEN`** => send, as before. - **`CONNECTING`** => wait for the next `open`/`close` event, then re-check. This covers the initial connect (replacing the constructor-level `untilOpen`) and also reconnect windows on reconnecting sockets: a call made while an attempt is in flight is sent once the connection opens, or rejected if the attempt fails, instead of erroring out during a routinely-occurring ~50ms window. - **`CLOSING`/`CLOSED`** => throw. `peer.request()` already handles a throwing send by rejecting the call, so callers get an immediate error instead of a hang or a ghost frame. Native WebSocket behavior is unchanged: `CONNECTING` only happens before the first open, and after a close all pending calls are rejected anyway. The guard only turns a send into a dead socket from a silent no-op into a proper rejection. One behavioral note: frames can no longer be buffered across a reconnect by the socket wrapper. I think that's correct: request ids are connection-scoped, so replaying frames onto a new connection is never safe, and callers already have to handle rejection since close rejects everything in flight. (The wait-during-CONNECTING path is safe for the same reason in reverse: if a call is rejected while its frame is waiting, `ClientPeer`'s id-guard drops the frame instead of sending it.)
1 parent e801713 commit 47dbd93

2 files changed

Lines changed: 115 additions & 10 deletions

File tree

‎packages/client/src/adapters/websocket/link-client.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,34 @@ import { ClientPeer } from '@orpc/standard-server-peer'
88
* Some env maybe not available WebSocket global
99
*/
1010
const WEBSOCKET_CONNECTING = 0 satisfies WebSocket['CONNECTING']
11+
const WEBSOCKET_OPEN = 1 satisfies WebSocket['OPEN']
1112

1213
export interface LinkWebsocketClientOptions {
13-
websocket: Pick<WebSocket, 'addEventListener' | 'send' | 'readyState'>
14+
websocket: Pick<WebSocket, 'addEventListener' | 'removeEventListener' | 'send' | 'readyState'>
1415
}
1516

1617
export class LinkWebsocketClient<T extends ClientContext> implements StandardLinkClient<T> {
1718
private readonly peer: ClientPeer
1819

1920
constructor(options: LinkWebsocketClientOptions) {
20-
const untilOpen = new Promise<void>((resolve) => {
21+
this.peer = new ClientPeer(async (message) => {
2122
if (options.websocket.readyState === WEBSOCKET_CONNECTING) {
22-
options.websocket.addEventListener('open', () => {
23-
resolve()
24-
}, { once: true })
23+
await new Promise<void>((resolve) => {
24+
const settle = () => {
25+
options.websocket.removeEventListener('open', settle)
26+
options.websocket.removeEventListener('close', settle)
27+
resolve()
28+
}
29+
30+
options.websocket.addEventListener('open', settle, { once: true })
31+
options.websocket.addEventListener('close', settle, { once: true })
32+
})
2533
}
26-
else {
27-
resolve()
34+
35+
if (options.websocket.readyState !== WEBSOCKET_OPEN) {
36+
throw new Error('Cannot send message, WebSocket is not open.')
2837
}
29-
})
3038

31-
this.peer = new ClientPeer(async (message) => {
32-
await untilOpen
3339
return options.websocket.send(message)
3440
})
3541

‎packages/client/src/adapters/websocket/rpc-link.test.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('rpcLink', () => {
1818
if (event === 'close')
1919
onClose = callback
2020
}),
21+
removeEventListener: vi.fn(),
2122
send: vi.fn(),
2223
}
2324

@@ -88,6 +89,7 @@ describe('rpcLink', () => {
8889
if (event === 'open')
8990
onOpen = callback
9091
}),
92+
removeEventListener: vi.fn(),
9193
send: vi.fn(),
9294
}
9395
const orpc = createORPCClient(new RPCLink({
@@ -99,6 +101,7 @@ describe('rpcLink', () => {
99101
await new Promise(resolve => setTimeout(resolve, 10))
100102
expect(websocket.send).toHaveBeenCalledTimes(0)
101103

104+
websocket.readyState = 1
102105
onOpen()
103106
await vi.waitFor(() => expect(websocket.send).toHaveBeenCalledTimes(1))
104107

@@ -107,4 +110,100 @@ describe('rpcLink', () => {
107110

108111
await promise
109112
})
113+
114+
describe('non-open sockets', () => {
115+
function createWebSocket(readyState: number) {
116+
const listeners = new Map<string, Set<(event?: any) => void>>()
117+
118+
return {
119+
readyState,
120+
addEventListener: vi.fn((event: string, callback: (event?: any) => void) => {
121+
if (!listeners.has(event)) {
122+
listeners.set(event, new Set())
123+
}
124+
listeners.get(event)!.add(callback)
125+
}),
126+
removeEventListener: vi.fn((event: string, callback: (event?: any) => void) => {
127+
listeners.get(event)?.delete(callback)
128+
}),
129+
send: vi.fn(),
130+
emit: (event: string, payload?: any) => {
131+
[...listeners.get(event) ?? []].forEach(callback => callback(payload))
132+
},
133+
}
134+
}
135+
136+
it('rejects instead of sending when socket is not open', async () => {
137+
const websocket = createWebSocket(3)
138+
const orpc = createORPCClient(new RPCLink({
139+
websocket,
140+
})) as any
141+
142+
await expect(orpc.ping('input')).rejects.toThrow('WebSocket is not open')
143+
expect(websocket.send).toHaveBeenCalledTimes(0)
144+
})
145+
146+
it('rejects instead of sending when socket closes between requests (reconnecting wrappers)', async () => {
147+
const websocket = createWebSocket(1)
148+
const orpc = createORPCClient(new RPCLink({
149+
websocket,
150+
})) as any
151+
152+
const promise = expect(orpc.ping('input')).resolves.toEqual('pong')
153+
await vi.waitFor(() => expect(websocket.send).toHaveBeenCalledTimes(1))
154+
const [id] = (await decodeRequestMessage(websocket.send.mock.calls[0]![0]))
155+
websocket.emit('message', { data: await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }) })
156+
await promise
157+
158+
websocket.readyState = 3
159+
160+
await expect(orpc.ping('input')).rejects.toThrow('WebSocket is not open')
161+
expect(websocket.send).toHaveBeenCalledTimes(1)
162+
})
163+
164+
it('waits during a reconnect window and sends once reopened', async () => {
165+
const websocket = createWebSocket(1)
166+
const orpc = createORPCClient(new RPCLink({
167+
websocket,
168+
})) as any
169+
170+
websocket.readyState = 0
171+
172+
const promise = expect(orpc.ping('input')).resolves.toEqual('pong')
173+
174+
await new Promise(resolve => setTimeout(resolve, 10))
175+
expect(websocket.send).toHaveBeenCalledTimes(0)
176+
177+
websocket.readyState = 1
178+
websocket.emit('open')
179+
await vi.waitFor(() => expect(websocket.send).toHaveBeenCalledTimes(1))
180+
181+
const [id] = (await decodeRequestMessage(websocket.send.mock.calls[0]![0]))
182+
websocket.emit('message', { data: await encodeResponseMessage(id, MessageType.RESPONSE, { body: { json: 'pong' }, status: 200, headers: {} }) })
183+
184+
await promise
185+
})
186+
187+
it('rejects without sending when a reconnect attempt fails', async () => {
188+
const websocket = createWebSocket(0)
189+
const orpc = createORPCClient(new RPCLink({
190+
websocket,
191+
})) as any
192+
193+
const promise = expect(orpc.ping('input')).rejects.toThrow(/aborted|not open/)
194+
195+
await new Promise(resolve => setTimeout(resolve, 10))
196+
expect(websocket.send).toHaveBeenCalledTimes(0)
197+
198+
websocket.readyState = 3
199+
websocket.emit('close')
200+
201+
await promise
202+
203+
websocket.readyState = 1
204+
websocket.emit('open')
205+
await new Promise(resolve => setTimeout(resolve, 10))
206+
expect(websocket.send).toHaveBeenCalledTimes(0)
207+
})
208+
})
110209
})

0 commit comments

Comments
 (0)