Skip to content

Commit bd6fad7

Browse files
authored
fix(emulator): clean up TCP transport event listeners on initialization failure (#284)
Fixes #274 - TCP transport leaks event listeners on initialization failure When start() fails (timeout or error during initialization), the 'initialized' and 'error' event listeners were not removed from the server instance, causing memory leaks in applications that retry connections. Root cause: The timeout and error handlers in start() deleted the server reference but didn't remove the attached event listeners before rejecting the Promise. This leaked 2 listeners per failed start() attempt. Fix: Add EventEmitter.prototype.removeAllListeners calls in both error paths (timeout and error handler) to clean up listeners before rejecting, matching the pattern already used in stop() (issue #253). Test coverage: Added 2 regression tests verifying no listeners remain after failed start() due to either timeout or port binding error. Both tests use EventEmitter-based mock with proper prototype inheritance. Pattern consistency: Matches the cleanup pattern established in stop() method from #253. Uses same EventEmitter.prototype.removeAllListeners.call() technique. Closes #274 Related to #253, #280
1 parent f333044 commit bd6fad7

2 files changed

Lines changed: 122 additions & 0 deletions

File tree

packages/emulator/src/transports/tcp.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Tests for TcpTransport
33
*/
44

5+
import { EventEmitter } from 'node:events'
6+
57
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
68

79
import { TcpTransport } from './tcp.js'
@@ -178,6 +180,122 @@ describe('TcpTransport', () => {
178180
})
179181

180182
describe('error handling', () => {
183+
it('should not leak event listeners when start() fails due to port binding error', async () => {
184+
// Test for issue #274: Event listeners should be cleaned up on initialization failure
185+
// Before the fix, when start() fails with an error event, the 'initialized' and 'error'
186+
// listeners remain attached to the server instance, causing a memory leak
187+
const { ServerTCP } = await import('modbus-serial')
188+
const bindError = new Error('listen EADDRINUSE: address already in use')
189+
let errorCallback: ((err: Error) => void) | undefined
190+
let localMockInstance: any
191+
;(ServerTCP as any).mockImplementationOnce((vector: any, _options: any) => {
192+
capturedServiceVector = vector
193+
initializedListener = undefined
194+
errorListener = undefined
195+
eventListeners = new Map()
196+
197+
// Create mock that extends EventEmitter so EventEmitter.prototype.removeAllListeners.call() works
198+
localMockInstance = Object.create(EventEmitter.prototype)
199+
Object.assign(localMockInstance, {
200+
close: jest.fn((cb: (err: Error | null) => void) => cb(null)),
201+
socks: new Map(),
202+
})
203+
204+
// Initialize EventEmitter state
205+
EventEmitter.call(localMockInstance)
206+
207+
// Override on() to track listeners in both EventEmitter and our test Map
208+
const originalOn = localMockInstance.on.bind(localMockInstance)
209+
localMockInstance.on = jest.fn((event: string, listener: any) => {
210+
if (event === 'initialized') {
211+
initializedListener = listener
212+
} else if (event === 'error') {
213+
errorListener = listener
214+
errorCallback = listener
215+
}
216+
// Track listeners in our Map
217+
if (!eventListeners.has(event)) {
218+
eventListeners.set(event, new Set())
219+
}
220+
eventListeners.get(event)!.add(listener)
221+
// Also add to real EventEmitter
222+
return originalOn(event, listener)
223+
})
224+
225+
// Simulate error during initialization (instead of initialized event)
226+
setImmediate(() => {
227+
if (errorCallback) {
228+
errorCallback(bindError)
229+
}
230+
})
231+
232+
return localMockInstance
233+
})
234+
235+
const testTransport = new TcpTransport({ host: 'localhost', port: 8502 })
236+
await expect(testTransport.start()).rejects.toThrow(
237+
'listen EADDRINUSE: address already in use'
238+
)
239+
240+
// After failed start(), verify no listeners remain attached
241+
// This prevents memory leaks when repeatedly attempting to start on a busy port
242+
expect(localMockInstance.listenerCount('initialized')).toBe(0)
243+
expect(localMockInstance.listenerCount('error')).toBe(0)
244+
})
245+
246+
it('should not leak event listeners when start() fails due to timeout', async () => {
247+
// Test for issue #274: Event listeners should be cleaned up on initialization timeout
248+
// Before the fix, when start() times out waiting for 'initialized' event, the listeners
249+
// remain attached to the server instance, causing a memory leak
250+
const { ServerTCP } = await import('modbus-serial')
251+
let localMockInstance: any
252+
;(ServerTCP as any).mockImplementationOnce((vector: any, _options: any) => {
253+
capturedServiceVector = vector
254+
initializedListener = undefined
255+
errorListener = undefined
256+
eventListeners = new Map()
257+
258+
// Create mock that extends EventEmitter so EventEmitter.prototype.removeAllListeners.call() works
259+
localMockInstance = Object.create(EventEmitter.prototype)
260+
Object.assign(localMockInstance, {
261+
close: jest.fn((cb: (err: Error | null) => void) => cb(null)),
262+
socks: new Map(),
263+
})
264+
265+
// Initialize EventEmitter state
266+
EventEmitter.call(localMockInstance)
267+
268+
// Override on() to track listeners in both EventEmitter and our test Map
269+
const originalOn = localMockInstance.on.bind(localMockInstance)
270+
localMockInstance.on = jest.fn((event: string, listener: any) => {
271+
if (event === 'initialized') {
272+
initializedListener = listener
273+
} else if (event === 'error') {
274+
errorListener = listener
275+
}
276+
// Track listeners in our Map
277+
if (!eventListeners.has(event)) {
278+
eventListeners.set(event, new Set())
279+
}
280+
eventListeners.get(event)!.add(listener)
281+
// Also add to real EventEmitter
282+
return originalOn(event, listener)
283+
})
284+
285+
// Don't emit initialized or error - simulate hanging server
286+
287+
return localMockInstance
288+
})
289+
290+
const testTransport = new TcpTransport({ host: 'localhost', port: 8502 })
291+
await expect(testTransport.start()).rejects.toThrow(/timeout/i)
292+
293+
// After failed start() due to timeout, verify no listeners remain attached
294+
// This prevents memory leaks when start() hangs and times out
295+
expect(localMockInstance.listenerCount('initialized')).toBe(0)
296+
expect(localMockInstance.listenerCount('error')).toBe(0)
297+
}, 15000) // Increase test timeout to allow for implementation timeout
298+
181299
it('should reject start() on port binding error', async () => {
182300
// Test for issue #254: start() should reject when port binding fails
183301
const { ServerTCP } = await import('modbus-serial')

packages/emulator/src/transports/tcp.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export class TcpTransport extends BaseTransport {
8989

9090
const timeout = setTimeout(() => {
9191
isInitializing = false
92+
EventEmitter.prototype.removeAllListeners.call(server, 'initialized')
93+
EventEmitter.prototype.removeAllListeners.call(server, 'error')
9294
delete this.server
9395
reject(new Error('TCP server initialization timeout after 10s'))
9496
}, 10000)
@@ -105,6 +107,8 @@ export class TcpTransport extends BaseTransport {
105107
if (isInitializing) {
106108
clearTimeout(timeout)
107109
isInitializing = false
110+
EventEmitter.prototype.removeAllListeners.call(server, 'initialized')
111+
EventEmitter.prototype.removeAllListeners.call(server, 'error')
108112
delete this.server
109113
reject(err)
110114
} else {

0 commit comments

Comments
 (0)