screeps-connectivity 0.15.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/stores/UserStore.ts +63 -40
- package/tests/stores/UserStore.test.ts +45 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b39a0c1: Fix duplicated console output when multiple parts of the app subscribe to the same `UserStore` channel. `subscribe()` now installs a single shared, ref-counted socket subscription and listener per channel instead of one listener per caller, so each incoming frame is processed and re-emitted exactly once. The server subscription and listener are torn down only after the last subscriber disposes.
|
|
8
|
+
|
|
3
9
|
## 0.15.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
package/src/stores/UserStore.ts
CHANGED
|
@@ -22,6 +22,15 @@ export class UserStore extends TypedStore<UserStoreEvents> {
|
|
|
22
22
|
private _worldStatus: WorldStatus | null = null
|
|
23
23
|
get worldStatusValue(): WorldStatus | null { return this._worldStatus }
|
|
24
24
|
private _mePromise: Promise<UserInfo> | null = null
|
|
25
|
+
// Ref-counted store-level fan-out, keyed by channel. SocketClient.subscribe() already ref-counts the
|
|
26
|
+
// server subscription, but SocketClient.on() installs one listener per call — so a shared listener
|
|
27
|
+
// must be installed once and reused, otherwise N callers process (and re-emit) every frame N times.
|
|
28
|
+
private readonly channelSubs = new Map<string, {
|
|
29
|
+
refCount: number
|
|
30
|
+
disposed: boolean
|
|
31
|
+
socketSub: Subscription | null
|
|
32
|
+
listenerSub: Subscription | null
|
|
33
|
+
}>()
|
|
25
34
|
|
|
26
35
|
constructor(http: HttpClient, socket: SocketClient, cache: Cache, logger?: Logger, maxConsoleSize = 100) {
|
|
27
36
|
super(logger)
|
|
@@ -84,56 +93,70 @@ export class UserStore extends TypedStore<UserStoreEvents> {
|
|
|
84
93
|
|
|
85
94
|
subscribe(channel: 'console' | 'cpu' | 'code' | 'set-active-branch'): Subscription {
|
|
86
95
|
this.logger.log('subscribe', channel)
|
|
87
|
-
let socketSub: Subscription | null = null
|
|
88
|
-
let listenerSub: Subscription | null = null
|
|
89
|
-
let disposed = false
|
|
90
96
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
97
|
+
// First caller for this channel installs the shared socket subscription + listener; later callers
|
|
98
|
+
// only bump the ref count so the frame is processed and re-emitted exactly once.
|
|
99
|
+
let entry = this.channelSubs.get(channel)
|
|
100
|
+
if (entry) {
|
|
101
|
+
entry.refCount++
|
|
102
|
+
} else {
|
|
103
|
+
entry = { refCount: 1, disposed: false, socketSub: null, listenerSub: null }
|
|
104
|
+
this.channelSubs.set(channel, entry)
|
|
105
|
+
const created = entry
|
|
106
|
+
const setup = async () => {
|
|
107
|
+
try {
|
|
108
|
+
const uid = this._userId ?? (await this.me())._id
|
|
109
|
+
if (created.disposed) return
|
|
110
|
+
const fullChannel = `user:${uid}/${channel}`
|
|
111
|
+
created.socketSub = this.socket.subscribe(fullChannel)
|
|
112
|
+
created.listenerSub = this.socket.on(fullChannel, (data) => {
|
|
113
|
+
if (channel === 'cpu') {
|
|
114
|
+
this._cpu = data as CpuStats
|
|
115
|
+
this.emit('user:cpu', this._cpu)
|
|
116
|
+
} else if (channel === 'console') {
|
|
117
|
+
const raw = data as { messages?: ConsoleMessage, error?: string }
|
|
118
|
+
const msg: ConsoleMessage = {
|
|
119
|
+
log: raw.messages?.log ?? [],
|
|
120
|
+
results: raw.messages?.results ?? [],
|
|
121
|
+
error: raw.messages?.error ?? [],
|
|
122
|
+
}
|
|
123
|
+
if (raw.error) {
|
|
124
|
+
msg.error.push(raw.error)
|
|
125
|
+
}
|
|
126
|
+
this.console.push(msg)
|
|
127
|
+
if (this.console.length > this.maxConsoleSize) {
|
|
128
|
+
this.console.splice(0, this.console.length - this.maxConsoleSize)
|
|
129
|
+
}
|
|
130
|
+
this.emit('user:console', { messages: msg })
|
|
131
|
+
} else if (channel === 'code') {
|
|
132
|
+
this.emit('user:code', data as { branch: string; modules: Record<string, string> })
|
|
133
|
+
} else if (channel === 'set-active-branch') {
|
|
134
|
+
this.emit('user:setActiveBranch', data as { activeName: 'activeWorld' | 'activeSim'; branch: string })
|
|
114
135
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
this.emit('user:setActiveBranch', data as { activeName: 'activeWorld' | 'activeSim'; branch: string })
|
|
136
|
+
})
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (!created.disposed) {
|
|
139
|
+
this.dispatchEvent(new ErrorEvent('error', { error: err instanceof Error ? err : new Error(String(err)) }))
|
|
120
140
|
}
|
|
121
|
-
})
|
|
122
|
-
} catch (err) {
|
|
123
|
-
if (!disposed) {
|
|
124
|
-
this.dispatchEvent(new ErrorEvent('error', { error: err instanceof Error ? err : new Error(String(err)) }))
|
|
125
141
|
}
|
|
126
142
|
}
|
|
143
|
+
void setup()
|
|
127
144
|
}
|
|
128
145
|
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
let disposed = false
|
|
131
147
|
return {
|
|
132
148
|
dispose: () => {
|
|
133
|
-
|
|
149
|
+
if (disposed) return
|
|
134
150
|
disposed = true
|
|
135
|
-
|
|
136
|
-
|
|
151
|
+
this.logger.log('unsubscribe', channel)
|
|
152
|
+
const current = this.channelSubs.get(channel)
|
|
153
|
+
if (!current) return
|
|
154
|
+
if (--current.refCount <= 0) {
|
|
155
|
+
current.disposed = true
|
|
156
|
+
current.socketSub?.dispose()
|
|
157
|
+
current.listenerSub?.dispose()
|
|
158
|
+
this.channelSubs.delete(channel)
|
|
159
|
+
}
|
|
137
160
|
},
|
|
138
161
|
}
|
|
139
162
|
}
|
|
@@ -133,4 +133,49 @@ describe('UserStore', () => {
|
|
|
133
133
|
sub.dispose()
|
|
134
134
|
expect(mockDispose).toHaveBeenCalled()
|
|
135
135
|
})
|
|
136
|
+
|
|
137
|
+
it('multiple console subscribers share one socket listener and do not double output', async () => {
|
|
138
|
+
const { store, socket } = makeStore()
|
|
139
|
+
await store.me()
|
|
140
|
+
const handlers: Array<(data: unknown) => void> = []
|
|
141
|
+
;(socket.on as ReturnType<typeof vi.fn>).mockImplementation((_ch: string, cb: (data: unknown) => void) => {
|
|
142
|
+
handlers.push(cb)
|
|
143
|
+
return { dispose: vi.fn() }
|
|
144
|
+
})
|
|
145
|
+
const eventSpy = vi.fn()
|
|
146
|
+
store.on('user:console', eventSpy)
|
|
147
|
+
|
|
148
|
+
// Two independent parts of the app subscribe to the same channel (e.g. the console panel and the
|
|
149
|
+
// custom-UI store). Regression: this used to install two socket listeners, doubling every frame.
|
|
150
|
+
store.subscribe('console')
|
|
151
|
+
store.subscribe('console')
|
|
152
|
+
await new Promise(r => setTimeout(r, 0))
|
|
153
|
+
|
|
154
|
+
// Exactly one socket listener and one server subscription regardless of caller count.
|
|
155
|
+
expect(handlers).toHaveLength(1)
|
|
156
|
+
expect(socket.subscribe).toHaveBeenCalledTimes(1)
|
|
157
|
+
|
|
158
|
+
// One incoming frame produces exactly one console entry and one event.
|
|
159
|
+
handlers[0]!({ messages: { log: ['line1'], results: [] } })
|
|
160
|
+
expect(store.console).toHaveLength(1)
|
|
161
|
+
expect(eventSpy).toHaveBeenCalledTimes(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('console socket subscription is ref-counted and dropped only after the last subscriber', async () => {
|
|
165
|
+
const { store, socket } = makeStore()
|
|
166
|
+
await store.me()
|
|
167
|
+
const mockDispose = vi.fn()
|
|
168
|
+
;(socket.subscribe as ReturnType<typeof vi.fn>).mockReturnValue({ dispose: mockDispose })
|
|
169
|
+
|
|
170
|
+
const a = store.subscribe('console')
|
|
171
|
+
const b = store.subscribe('console')
|
|
172
|
+
await new Promise(r => setTimeout(r, 0))
|
|
173
|
+
expect(socket.subscribe).toHaveBeenCalledTimes(1)
|
|
174
|
+
|
|
175
|
+
a.dispose()
|
|
176
|
+
expect(mockDispose).not.toHaveBeenCalled() // second subscriber still active
|
|
177
|
+
|
|
178
|
+
b.dispose()
|
|
179
|
+
expect(mockDispose).toHaveBeenCalledTimes(1)
|
|
180
|
+
})
|
|
136
181
|
})
|