From 9dd6c7720a12ffb8d0aabfc88a1434def03fc332 Mon Sep 17 00:00:00 2001 From: Faithful Ojebiyi Date: Wed, 21 Oct 2026 09:53:14 +0100 Subject: [PATCH] Fix listener leak on the shared port-probe server probePort reuses one net.Server and adds a once('listening') and a once('error') listener per probe, but only one of the two fires, so the other stays attached. Consecutive probes of the same kind accumulate them until Node warns MaxListenersExceededWarning. Each handler now removes the other. --- src/util/socket-util.ts | 14 +++++--- test/socket-util.spec.ts | 71 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 test/socket-util.spec.ts diff --git a/src/util/socket-util.ts b/src/util/socket-util.ts index bf871aa9f..6747122ca 100644 --- a/src/util/socket-util.ts +++ b/src/util/socket-util.ts @@ -68,12 +68,18 @@ function probePort(interfaceIp: string, port: number): Promise { port, ipv6Only: interfaceIp === '::1' }); - probeServer.once('listening', () => { + // Only one of these events fires per probe. Each handler removes the other, or the + // unfired listener would stay on the shared probeServer and accumulate on every probe. + const onListening = () => { + probeServer.removeListener('error', onError); probeServer.close(() => resolve(false)); - }); - probeServer.once('error', () => { + }; + const onError = () => { + probeServer.removeListener('listening', onListening); resolve(true); - }); + }; + probeServer.once('listening', onListening); + probeServer.once('error', onError); }) ); diff --git a/test/socket-util.spec.ts b/test/socket-util.spec.ts new file mode 100644 index 000000000..ef80a2c6e --- /dev/null +++ b/test/socket-util.spec.ts @@ -0,0 +1,71 @@ +import * as net from 'net'; + +import { expect, nodeOnly } from './test-utils'; +import { isLocalPortActive } from '../src/util/socket-util'; + +// A free port: bind to port 0, note the port we were given, then release it. +const getFreePort = () => new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen({ host: '127.0.0.1', port: 0 }, () => { + const { port } = server.address() as net.AddressInfo; + server.close(() => resolve(port)); + }); +}); + +nodeOnly(() => { + describe("isLocalPortActive", () => { + + it("reports an unused port as inactive and a bound port as active", async () => { + const freePort = await getFreePort(); + expect(await isLocalPortActive('127.0.0.1', freePort)).to.equal(false); + + const server = net.createServer(); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + const { port: boundPort } = server.address() as net.AddressInfo; + + try { + expect(await isLocalPortActive('127.0.0.1', boundPort)).to.equal(true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("doesn't leak listeners on the shared probe server across many probes", async () => { + const warnings: Error[] = []; + const onWarning = (warning: Error) => { + if (warning.name === 'MaxListenersExceededWarning') warnings.push(warning); + }; + process.on('warning', onWarning); + + try { + // Distinct ports, so every check runs a real probe rather than hitting the cache. + // Each kind runs in a row, more often than the default limit of 10 listeners: an + // 'error' event would also clear stale 'error' listeners left by earlier probes. + for (let i = 0; i < 12; i++) { + expect(await isLocalPortActive('127.0.0.1', await getFreePort())).to.equal(false); + } + + const servers: net.Server[] = []; + for (let i = 0; i < 12; i++) { + const server = net.createServer(); + await new Promise((resolve) => server.listen({ host: '127.0.0.1', port: 0 }, resolve)); + servers.push(server); + const { port } = server.address() as net.AddressInfo; + expect(await isLocalPortActive('127.0.0.1', port)).to.equal(true); + } + + await Promise.all(servers.map((server) => + new Promise((resolve) => server.close(() => resolve())) + )); + + // Warnings are emitted asynchronously: + await new Promise((resolve) => setImmediate(resolve)); + expect(warnings).to.deep.equal([]); + } finally { + process.removeListener('warning', onWarning); + } + }); + + }); +});