Skip to content

Commit e656c81

Browse files
antfubotantfu
authored andcommitted
fix(rpc): stop leaking an open listener when posting on a closing/closed socket
Only queue an 'open' listener on the ws client's post() when the socket is CONNECTING. On CLOSING/CLOSED, notify via onError instead of registering a listener that will never fire. Also drop the queued listener (and remove the paired close listener) if the socket closes before it opens. Implements plans/011-ws-client-post-closed.md; removing the plan file now that it's done.
1 parent a77c0f8 commit e656c81

4 files changed

Lines changed: 160 additions & 186 deletions

File tree

packages/devframe/src/rpc/transports/ws-client.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,25 @@ export function createWsRpcChannel(options: WsRpcChannelOptions): ChannelOptions
6666
post: (data: string) => {
6767
if (ws.readyState === WebSocket.OPEN) {
6868
ws.send(data)
69+
return
6970
}
70-
else {
71-
function handler() {
72-
ws.send(data)
73-
ws.removeEventListener('open', handler)
71+
if (ws.readyState === WebSocket.CONNECTING) {
72+
const onOpen = () => {
73+
cleanup()
74+
if (ws.readyState === WebSocket.OPEN)
75+
ws.send(data)
76+
}
77+
const onClose = () => cleanup()
78+
function cleanup() {
79+
ws.removeEventListener('open', onOpen)
80+
ws.removeEventListener('close', onClose)
7481
}
75-
ws.addEventListener('open', handler)
82+
ws.addEventListener('open', onOpen)
83+
ws.addEventListener('close', onClose) // drop the queued send if it closes first
84+
return
7685
}
86+
// CLOSING or CLOSED: the socket will never (re)open on this channel.
87+
onError(new Error('Devframe WebSocket is not open; message dropped'))
7788
},
7889
serialize: (msg: any): string => {
7990
let method: string | undefined

packages/devframe/src/rpc/transports/ws.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,149 @@ describe('ws auth token in URL', () => {
3838
})
3939
})
4040

41+
describe('ws client post on a non-open socket', () => {
42+
class FakeWS {
43+
static OPEN = 1
44+
static CONNECTING = 0
45+
static CLOSING = 2
46+
static CLOSED = 3
47+
48+
readyState: number
49+
listeners = new Map<string, Set<() => void>>()
50+
sent: string[] = []
51+
52+
constructor(public url: string, readyState: number) {
53+
this.readyState = readyState
54+
}
55+
56+
addEventListener(type: string, handler: () => void) {
57+
let set = this.listeners.get(type)
58+
if (!set) {
59+
set = new Set()
60+
this.listeners.set(type, set)
61+
}
62+
set.add(handler)
63+
}
64+
65+
removeEventListener(type: string, handler: () => void) {
66+
this.listeners.get(type)?.delete(handler)
67+
}
68+
69+
send(data: string) {
70+
this.sent.push(data)
71+
}
72+
73+
dispatch(type: string) {
74+
for (const handler of this.listeners.get(type) ?? [])
75+
handler()
76+
}
77+
}
78+
79+
// Builds a `WebSocket` constructor fixed at the given `readyState`,
80+
// stashing every instance it creates into `instances` (avoids aliasing
81+
// `this` inside the constructor).
82+
function fakeWsCtor(readyState: number, instances: FakeWS[]) {
83+
return class extends FakeWS {
84+
constructor(url: string) {
85+
super(url, readyState)
86+
instances.push(this)
87+
}
88+
}
89+
}
90+
91+
it('does not queue an open listener when posting on a CLOSED socket, and notifies onError', () => {
92+
const errors: Error[] = []
93+
const instances: FakeWS[] = []
94+
vi.stubGlobal('WebSocket', fakeWsCtor(FakeWS.CLOSED, instances))
95+
try {
96+
// Baseline: `createWsRpcChannel` itself registers one 'open' listener
97+
// (for `onConnected`) regardless of readyState — that one is expected
98+
// to stick around; `post` must not add another on top of it.
99+
const channel = createWsRpcChannel({ url: 'ws://127.0.0.1:1', onError: e => errors.push(e) })
100+
const ws = instances[0]!
101+
const baseline = ws.listeners.get('open')?.size ?? 0
102+
channel.post!('hello')
103+
expect(ws.listeners.get('open')?.size ?? 0).toBe(baseline)
104+
expect(ws.sent).toEqual([])
105+
expect(errors).toHaveLength(1)
106+
}
107+
finally {
108+
vi.stubGlobal('WebSocket', WebSocket)
109+
}
110+
})
111+
112+
it('does not queue an open listener when posting on a CLOSING socket, and notifies onError', () => {
113+
const errors: Error[] = []
114+
const instances: FakeWS[] = []
115+
vi.stubGlobal('WebSocket', fakeWsCtor(FakeWS.CLOSING, instances))
116+
try {
117+
const channel = createWsRpcChannel({ url: 'ws://127.0.0.1:1', onError: e => errors.push(e) })
118+
const ws = instances[0]!
119+
const baseline = ws.listeners.get('open')?.size ?? 0
120+
channel.post!('hello')
121+
expect(ws.listeners.get('open')?.size ?? 0).toBe(baseline)
122+
expect(ws.sent).toEqual([])
123+
expect(errors).toHaveLength(1)
124+
}
125+
finally {
126+
vi.stubGlobal('WebSocket', WebSocket)
127+
}
128+
})
129+
130+
it('queues exactly one open listener on CONNECTING and cleans it up (and the close listener) after open fires', () => {
131+
const errors: Error[] = []
132+
const instances: FakeWS[] = []
133+
vi.stubGlobal('WebSocket', fakeWsCtor(FakeWS.CONNECTING, instances))
134+
try {
135+
// Baseline: `createWsRpcChannel` already registers one 'open' listener
136+
// (`onConnected`) and one 'close' listener (`onDisconnected`) up front;
137+
// `post`'s queued listeners are on top of those and must clean up back
138+
// down to this baseline.
139+
const channel = createWsRpcChannel({ url: 'ws://127.0.0.1:1', onError: e => errors.push(e) })
140+
const ws = instances[0]!
141+
const openBaseline = ws.listeners.get('open')?.size ?? 0
142+
const closeBaseline = ws.listeners.get('close')?.size ?? 0
143+
channel.post!('hello')
144+
expect(ws.listeners.get('open')?.size ?? 0).toBe(openBaseline + 1)
145+
expect(ws.listeners.get('close')?.size ?? 0).toBe(closeBaseline + 1)
146+
147+
ws.readyState = FakeWS.OPEN
148+
ws.dispatch('open')
149+
150+
expect(ws.sent).toEqual(['hello'])
151+
expect(ws.listeners.get('open')?.size ?? 0).toBe(openBaseline)
152+
expect(ws.listeners.get('close')?.size ?? 0).toBe(closeBaseline)
153+
expect(errors).toHaveLength(0)
154+
}
155+
finally {
156+
vi.stubGlobal('WebSocket', WebSocket)
157+
}
158+
})
159+
160+
it('drops a queued post (no send) if the socket closes before it opens', () => {
161+
const errors: Error[] = []
162+
const instances: FakeWS[] = []
163+
vi.stubGlobal('WebSocket', fakeWsCtor(FakeWS.CONNECTING, instances))
164+
try {
165+
const channel = createWsRpcChannel({ url: 'ws://127.0.0.1:1', onError: e => errors.push(e) })
166+
const ws = instances[0]!
167+
const openBaseline = ws.listeners.get('open')?.size ?? 0
168+
const closeBaseline = ws.listeners.get('close')?.size ?? 0
169+
channel.post!('hello')
170+
171+
ws.readyState = FakeWS.CLOSED
172+
ws.dispatch('close')
173+
174+
expect(ws.sent).toEqual([])
175+
expect(ws.listeners.get('open')?.size ?? 0).toBe(openBaseline)
176+
expect(ws.listeners.get('close')?.size ?? 0).toBe(closeBaseline)
177+
}
178+
finally {
179+
vi.stubGlobal('WebSocket', WebSocket)
180+
}
181+
})
182+
})
183+
41184
describe('devframe rpc', () => {
42185
it('should work w/ ws transport', async () => {
43186
// Use 127.0.0.1 on both client and server so they agree on the

plans/011-ws-client-post-closed.md

Lines changed: 0 additions & 180 deletions
This file was deleted.

plans/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ changes are allowed as long as they're marked).
2323
| 008 | Cap hub terminal buffer + reject restart-after-terminate | bug | P2 | S || TODO |
2424
| 009 | Fix two server-side streaming lifecycle leaks | bug | P2 | S-M || TODO |
2525
| 010 | Initialize client shared state once across trust flips | bug | P2 | S || TODO |
26-
| 011 | Don't leak/drop when WS client posts on a closing socket | bug | P2 | S || TODO |
26+
| 011 | Don't leak/drop when WS client posts on a closing socket | bug | P2 | S || DONE |
2727
| 012 | Atomic, non-throwing `createStorage` writes | bug | P2 | M || TODO |
2828
| 013 | De-duplicate git parse helpers into `node/git.ts` | tech-debt | P2 | S | 004 | TODO |
2929
| 014 | Parallelize the `git:show` static dump | perf | P2 | S-M | 004, 013 | TODO |

0 commit comments

Comments
 (0)