Skip to content

Commit 8ce11a0

Browse files
authored
fix(emulator): remove event listeners in TCP/RTU transport stop() (#270)
Event listeners registered in start() were never cleaned up during stop(), causing memory leaks in applications with repeated start/stop cycles. TCP Transport: - Each start() added 'initialized' and 'error' listeners that persisted - Now stop() calls removeAllListeners() for both events before closing RTU Transport: - The 'error' listener added in start() persisted for server lifetime - Now stop() calls removeAllListeners('error') before closing Technical Implementation: - Uses EventEmitter.prototype.removeAllListeners.call() to access the method since modbus-serial doesn't expose it in TypeScript declarations - Added regression tests verifying listeners are cleaned up across cycles - Improved test assertions with constants and explicit leak verification Closes #253
1 parent f19630d commit 8ce11a0

4 files changed

Lines changed: 136 additions & 2 deletions

File tree

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

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { RtuTransport } from './rtu.js'
88

99
// Store captured service vector for testing
1010
let capturedServiceVector: any = null
11+
let mockServerInstance: any
12+
let eventListeners: Map<string, Set<(...args: unknown[]) => void>>
13+
// Track removeAllListeners calls across all mock instances
14+
let allRemoveAllListenersCalls: Array<{ event?: string }> = []
1115

1216
// Mock modbus-serial
1317
jest.mock('modbus-serial', () => {
@@ -17,17 +21,37 @@ jest.mock('modbus-serial', () => {
1721
.mockImplementation((vector: any, options: any, _serialportOptions?: any) => {
1822
// Capture service vector for testing
1923
capturedServiceVector = vector
24+
eventListeners = new Map()
2025

2126
// Call openCallback immediately to simulate successful connection
2227
if (options.openCallback) {
2328
setImmediate(() => options.openCallback(null))
2429
}
2530

26-
return {
31+
mockServerInstance = {
2732
close: jest.fn((cb: (err: Error | null) => void) => cb(null)),
28-
on: jest.fn(),
33+
on: jest.fn((event: string, listener: (...args: unknown[]) => void) => {
34+
// Track listeners
35+
if (!eventListeners.has(event)) {
36+
eventListeners.set(event, new Set())
37+
}
38+
eventListeners.get(event)!.add(listener)
39+
}),
40+
removeAllListeners: jest.fn((event?: string) => {
41+
allRemoveAllListenersCalls.push({ event })
42+
if (event) {
43+
eventListeners.delete(event)
44+
} else {
45+
eventListeners.clear()
46+
}
47+
}),
48+
listenerCount: jest.fn((event: string) => {
49+
return eventListeners.get(event)?.size ?? 0
50+
}),
2951
socks: new Map(),
3052
}
53+
54+
return mockServerInstance
3155
}),
3256
}
3357
})
@@ -37,6 +61,8 @@ describe('RtuTransport', () => {
3761

3862
beforeEach(() => {
3963
capturedServiceVector = null
64+
allRemoveAllListenersCalls = []
65+
jest.clearAllMocks()
4066
})
4167

4268
afterEach(async () => {
@@ -86,6 +112,40 @@ describe('RtuTransport', () => {
86112
await transport.start()
87113
await expect(transport.start()).rejects.toThrow('Transport already started')
88114
})
115+
116+
it('should not accumulate event listeners after repeated start/stop cycles', async () => {
117+
// This test verifies the fix for issue #253: Event listener memory leak
118+
// RTU transport has the same issue as TCP - 'error' listener is never removed
119+
120+
// Track listener counts across multiple transports
121+
const listenerCounts: number[] = []
122+
123+
// Run 5 start/stop cycles
124+
for (let i = 0; i < 5; i++) {
125+
transport = new RtuTransport({ port: '/dev/ttyUSB0' })
126+
await transport.start()
127+
128+
// Record listener count after start (only 'error' listener in RTU)
129+
const count = mockServerInstance.listenerCount('error')
130+
listenerCounts.push(count)
131+
132+
await transport.stop()
133+
}
134+
135+
// After the fix: all cycles should have the same listener count (1 listener per cycle)
136+
// Before the fix: listener count would grow with each cycle
137+
const EXPECTED_LISTENERS_PER_CYCLE = 1 // only 'error' listener in RTU
138+
const CYCLE_COUNT = 5
139+
140+
// Verify listener counts remain constant (primary regression test)
141+
expect(listenerCounts).toEqual(Array(CYCLE_COUNT).fill(EXPECTED_LISTENERS_PER_CYCLE))
142+
143+
// Verify no listener accumulation - the most important behavior
144+
const minCount = Math.min(...listenerCounts)
145+
const maxCount = Math.max(...listenerCounts)
146+
expect(maxCount).toBe(minCount) // All counts should be equal (no growth)
147+
expect(maxCount).toBe(EXPECTED_LISTENERS_PER_CYCLE) // Should match expected count
148+
})
89149
})
90150

91151
describe('request/response handling', () => {
@@ -144,6 +204,7 @@ describe('RtuTransport', () => {
144204
closeCallback = cb
145205
}),
146206
on: jest.fn(),
207+
removeAllListeners: jest.fn(),
147208
socks: new Map(),
148209
}
149210
})
@@ -173,6 +234,7 @@ describe('RtuTransport', () => {
173234
errorHandler = handler
174235
}
175236
}),
237+
removeAllListeners: jest.fn(),
176238
socks: new Map(),
177239
}
178240
})

packages/emulator/src/transports/rtu.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* This implementation uses modbus-serial ServerSerial for protocol handling.
55
*/
66

7+
import { EventEmitter } from 'events'
8+
79
import { ServerSerial } from 'modbus-serial'
810
import type { IServiceVector } from 'modbus-serial/ServerTCP'
911

@@ -121,6 +123,10 @@ export class RtuTransport extends BaseTransport {
121123
return
122124
}
123125

126+
// Clean up event listeners to prevent memory leaks (issue #253)
127+
// Use EventEmitter.prototype since modbus-serial doesn't expose these methods in types
128+
EventEmitter.prototype.removeAllListeners.call(this.server, 'error')
129+
124130
return new Promise<void>((resolve, reject) => {
125131
if (!this.server) {
126132
resolve()

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ let capturedServiceVector: any = null
1313
let mockServerInstance: any
1414
let initializedListener: (() => void) | undefined
1515
let errorListener: ((err: Error) => void) | undefined
16+
let eventListeners: Map<string, Set<(...args: unknown[]) => void>>
17+
// Track removeAllListeners calls across all mock instances
18+
let allRemoveAllListenersCalls: Array<{ event?: string }> = []
1619

1720
// Mock modbus-serial
1821
jest.mock('modbus-serial', () => {
@@ -24,6 +27,7 @@ jest.mock('modbus-serial', () => {
2427
// Reset listeners
2528
initializedListener = undefined
2629
errorListener = undefined
30+
eventListeners = new Map()
2731

2832
// Create mock instance
2933
mockServerInstance = {
@@ -34,6 +38,22 @@ jest.mock('modbus-serial', () => {
3438
} else if (event === 'error') {
3539
errorListener = listener
3640
}
41+
// Track listeners
42+
if (!eventListeners.has(event)) {
43+
eventListeners.set(event, new Set())
44+
}
45+
eventListeners.get(event)!.add(listener)
46+
}),
47+
removeAllListeners: jest.fn((event?: string) => {
48+
allRemoveAllListenersCalls.push({ event })
49+
if (event) {
50+
eventListeners.delete(event)
51+
} else {
52+
eventListeners.clear()
53+
}
54+
}),
55+
listenerCount: jest.fn((event: string) => {
56+
return eventListeners.get(event)?.size ?? 0
3757
}),
3858
socks: new Map(),
3959
}
@@ -55,6 +75,7 @@ describe('TcpTransport', () => {
5575

5676
beforeEach(() => {
5777
capturedServiceVector = null
78+
allRemoveAllListenersCalls = []
5879
jest.clearAllMocks()
5980
})
6081

@@ -94,6 +115,43 @@ describe('TcpTransport', () => {
94115
await transport.start()
95116
await expect(transport.start()).rejects.toThrow('Transport already started')
96117
})
118+
119+
it('should not accumulate event listeners after repeated start/stop cycles', async () => {
120+
// This test verifies the fix for issue #253: Event listener memory leak
121+
// Before the fix, listeners are never removed during stop()
122+
// Each start() adds listeners, so repeated cycles on the same server accumulate them
123+
124+
// Track listener counts across multiple transports using the same mock server
125+
const listenerCounts: number[] = []
126+
127+
// Run 5 start/stop cycles
128+
for (let i = 0; i < 5; i++) {
129+
transport = new TcpTransport({ host: 'localhost', port: 502 })
130+
await transport.start()
131+
132+
// Record listener count after start
133+
const count =
134+
mockServerInstance.listenerCount('initialized') +
135+
mockServerInstance.listenerCount('error')
136+
listenerCounts.push(count)
137+
138+
await transport.stop()
139+
}
140+
141+
// After the fix: all cycles should have the same listener count (2 listeners per cycle)
142+
// Before the fix: listener count would grow with each cycle
143+
const EXPECTED_LISTENERS_PER_CYCLE = 2 // 'initialized' + 'error'
144+
const CYCLE_COUNT = 5
145+
146+
// Verify listener counts remain constant (primary regression test)
147+
expect(listenerCounts).toEqual(Array(CYCLE_COUNT).fill(EXPECTED_LISTENERS_PER_CYCLE))
148+
149+
// Verify no listener accumulation - the most important behavior
150+
const minCount = Math.min(...listenerCounts)
151+
const maxCount = Math.max(...listenerCounts)
152+
expect(maxCount).toBe(minCount) // All counts should be equal (no growth)
153+
expect(maxCount).toBe(EXPECTED_LISTENERS_PER_CYCLE) // Should match expected count
154+
})
97155
})
98156

99157
describe('request/response handling', () => {
@@ -140,6 +198,7 @@ describe('TcpTransport', () => {
140198
errorListener = listener
141199
}
142200
}),
201+
removeAllListeners: jest.fn(),
143202
socks: new Map(),
144203
}
145204

packages/emulator/src/transports/tcp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* This implementation uses modbus-serial ServerTCP for protocol handling.
55
*/
66

7+
import { EventEmitter } from 'events'
8+
79
import { ServerTCP } from 'modbus-serial'
810
import type { IServiceVector } from 'modbus-serial/ServerTCP'
911

@@ -101,6 +103,11 @@ export class TcpTransport extends BaseTransport {
101103
return
102104
}
103105

106+
// Clean up event listeners to prevent memory leaks (issue #253)
107+
// Use EventEmitter.prototype since modbus-serial doesn't expose these methods in types
108+
EventEmitter.prototype.removeAllListeners.call(this.server, 'initialized')
109+
EventEmitter.prototype.removeAllListeners.call(this.server, 'error')
110+
104111
return new Promise<void>((resolve, reject) => {
105112
if (!this.server) {
106113
resolve()

0 commit comments

Comments
 (0)