Skip to content

Commit 2b98f3d

Browse files
committed
Merge remote-tracking branch 'origin/main' into spicy-experts-wear
# Conflicts: # plans/README.md
2 parents 19abeb5 + e656c81 commit 2b98f3d

11 files changed

Lines changed: 252 additions & 367 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

packages/devframe/src/utils/shared-state.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,20 @@ describe('shared-state', () => {
304304
expect(state.syncIds.has('sync-2')).toBe(true)
305305
})
306306

307+
it('caps syncIds and evicts oldest-first (FIFO), keeping recent de-dup', () => {
308+
const state = createSharedState({ initialValue: { count: 0 } })
309+
for (let i = 0; i < 1500; i++) {
310+
state.mutate((draft) => {
311+
draft.count = i
312+
}, `sync-${i}`)
313+
}
314+
expect(state.syncIds.size).toBeLessThanOrEqual(1000)
315+
// The most recent id is retained (still de-duped)...
316+
expect(state.syncIds.has('sync-1499')).toBe(true)
317+
// ...the oldest was evicted.
318+
expect(state.syncIds.has('sync-0')).toBe(false)
319+
})
320+
307321
it('should able to sync between two shared states', () => {
308322
const state1 = createSharedState({
309323
initialValue: { count: 0 },

packages/devframe/src/utils/shared-state.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,22 @@ export interface SharedStateOptions<T> {
7171
enablePatches?: boolean
7272
}
7373

74+
/**
75+
* Upper bound on retained syncIds. Loop echoes arrive near-immediately, so a
76+
* generous window preserves de-dup while capping memory on long-lived,
77+
* frequently-mutated states (e.g. a 1s terminal poll).
78+
*/
79+
const MAX_SYNC_IDS = 1000
80+
81+
function rememberSyncId(syncIds: Set<string>, syncId: string): void {
82+
syncIds.add(syncId)
83+
if (syncIds.size > MAX_SYNC_IDS) {
84+
const oldest = syncIds.values().next().value
85+
if (oldest !== undefined)
86+
syncIds.delete(oldest)
87+
}
88+
}
89+
7490
export function createSharedState<T extends object>(
7591
options: SharedStateOptions<T>,
7692
): SharedState<T> {
@@ -91,15 +107,15 @@ export function createSharedState<T extends object>(
91107
return
92108
enableImmerPatches()
93109
state = applyPatches(state as unknown as Objectish, patches as unknown as Patch[]) as T
94-
syncIds.add(syncId)
110+
rememberSyncId(syncIds, syncId)
95111
events.emit('updated', state, undefined, syncId)
96112
},
97113
mutate: (fn, syncId = nanoid()) => {
98114
// Avoid loop syncs
99115
if (syncIds.has(syncId))
100116
return
101117

102-
syncIds.add(syncId)
118+
rememberSyncId(syncIds, syncId)
103119
if (enablePatches) {
104120
const [newState, patches] = produceWithPatches(
105121
state as unknown as Objectish,

0 commit comments

Comments
 (0)