|
2 | 2 | * Tests for TcpTransport |
3 | 3 | */ |
4 | 4 |
|
| 5 | +import { EventEmitter } from 'node:events' |
| 6 | + |
5 | 7 | import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals' |
6 | 8 |
|
7 | 9 | import { TcpTransport } from './tcp.js' |
@@ -178,6 +180,122 @@ describe('TcpTransport', () => { |
178 | 180 | }) |
179 | 181 |
|
180 | 182 | 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 | + |
181 | 299 | it('should reject start() on port binding error', async () => { |
182 | 300 | // Test for issue #254: start() should reject when port binding fails |
183 | 301 | const { ServerTCP } = await import('modbus-serial') |
|
0 commit comments