From 2386cbd9b916eae476b54c9a292bb404bbe8aa81 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 12 May 2026 12:46:31 +0900 Subject: [PATCH] refactor(port-utils): drop Bun.serve in favor of node:net probe isPortAvailable() previously bound a one-shot Bun.serve and stopped it. That call was reachable from the plugin bundle through src/shared/index.ts barrel re-export and crashed on Electron. Switch to node:net.createServer().listen(port, host), which Bun fully implements as well. Adds a 2s safety timeout and removes both "error" and "listening" handlers on resolution to prevent listener leaks. Behavior is bit-equivalent: returns true iff a server can bind to (host, port) right now. Test file is fully rewritten away from stale Bun.serve mocking. New tests exercise: free-port detection via port 0, EADDRINUSE handling via a real net.createServer blocker, findAvailablePort range exhaustion, getAvailableServerPort auto-selection, 127.0.0.1 default hostname binding, and probe-server resource cleanup. --- src/shared/port-utils.test.ts | 650 ++++++++++++++++++++-------------- src/shared/port-utils.ts | 55 ++- 2 files changed, 421 insertions(+), 284 deletions(-) diff --git a/src/shared/port-utils.test.ts b/src/shared/port-utils.test.ts index 3b1be1cf9..46fbc65b4 100644 --- a/src/shared/port-utils.test.ts +++ b/src/shared/port-utils.test.ts @@ -1,291 +1,393 @@ -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" -import { - isPortAvailable, - findAvailablePort, - getAvailableServerPort, - DEFAULT_SERVER_PORT, -} from "./port-utils" +import { createServer, Server } from "node:net" +import type { AddressInfo } from "node:net" +import { networkInterfaces } from "node:os" -const HOSTNAME = "127.0.0.1" -const REAL_PORT_SEARCH_WINDOW = 200 +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" -function supportsRealSocketBinding(): boolean { - try { - const server = Bun.serve({ - port: 0, - hostname: HOSTNAME, - fetch: () => new Response("probe"), +import { DEFAULT_SERVER_PORT, findAvailablePort, getAvailableServerPort, isPortAvailable } from "./port-utils" + +const DEFAULT_HOSTNAME = "127.0.0.1" +const MAX_PORT_ATTEMPTS = 20 +const EXHAUSTED_PORT_COUNT = MAX_PORT_ATTEMPTS + 1 +const CONTIGUOUS_SEARCH_WINDOW = 256 +const CONTIGUOUS_SEARCH_SEEDS = 8 + +const trackedServers = new Set() + +type TimeoutProbeResult = { + closeCallCount: number + isAvailable: boolean + server: Server | undefined +} + +function getRequiredPropertyDescriptor(target: object, propertyName: string): PropertyDescriptor { + const descriptor = Object.getOwnPropertyDescriptor(target, propertyName) + if (!descriptor) { + throw new Error(`Expected ${propertyName} property descriptor`) + } + + return descriptor +} + +function isTcpAddress(address: ReturnType): address is AddressInfo { + return typeof address === "object" && address !== null && "port" in address +} + +function getServerPort(server: Server): number { + const address = server.address() + if (!isTcpAddress(address)) { + throw new Error("Expected TCP server address") + } + + return address.port +} + +function getAlternateIpv4Hostname(): string | undefined { + for (const addresses of Object.values(networkInterfaces())) { + if (!addresses) continue + + for (const address of addresses) { + if (address.family === "IPv4" && !address.internal && address.address !== DEFAULT_HOSTNAME) { + return address.address + } + } + } + + return undefined +} + +function startTrackedServer(port: number, hostname: string = DEFAULT_HOSTNAME): Promise { + return new Promise((resolve, reject) => { + const server = createServer() + + const removeListeners = (): void => { + server.removeListener("error", handleError) + server.removeListener("listening", handleListening) + } + + const handleError = (error: Error): void => { + removeListeners() + trackedServers.delete(server) + reject(error) + } + + const handleListening = (): void => { + removeListeners() + trackedServers.add(server) + resolve(server) + } + + server.once("error", handleError) + server.once("listening", handleListening) + + try { + server.listen(port, hostname) + } catch (error) { + removeListeners() + trackedServers.delete(server) + reject(error) + } + }) +} + +function closeTrackedServer(server: Server): Promise { + trackedServers.delete(server) + + if (!server.listening) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error) + return + } + + resolve() }) - server.stop(true) + }) +} + +async function closeAllTrackedServers(): Promise { + await Promise.all(Array.from(trackedServers).map((server) => closeTrackedServer(server))) +} + +async function getReleasedPort(hostname: string = DEFAULT_HOSTNAME): Promise { + const server = await startTrackedServer(0, hostname) + const port = getServerPort(server) + await closeTrackedServer(server) + + return port +} + +async function canBindContiguousPorts( + startPort: number, + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + const servers: Server[] = [] + + try { + for (let offset = 0; offset < portCount; offset++) { + servers.push(await startTrackedServer(startPort + offset, hostname)) + } + return true } catch { return false + } finally { + await Promise.all(servers.map((server) => closeTrackedServer(server))) } } -const canBindRealSockets = supportsRealSocketBinding() +async function findContiguousAvailableStart( + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + for (let seedAttempt = 0; seedAttempt < CONTIGUOUS_SEARCH_SEEDS; seedAttempt++) { + const seedPort = await getReleasedPort(hostname) + const maxStartPort = Math.min(65_535 - portCount + 1, seedPort + CONTIGUOUS_SEARCH_WINDOW) -describe("port-utils", () => { - if (canBindRealSockets) { - function startRealBlocker(port: number = 0) { - return Bun.serve({ - port, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - } - - async function findContiguousAvailableStart(length: number): Promise { - const probe = startRealBlocker() - const seedPort = probe.port - probe.stop(true) - - for (let candidate = seedPort; candidate < seedPort + REAL_PORT_SEARCH_WINDOW; candidate++) { - const checks = await Promise.all( - Array.from({ length }, async (_, offset) => isPortAvailable(candidate + offset, HOSTNAME)) - ) - if (checks.every(Boolean)) { - return candidate - } + for (let candidatePort = seedPort; candidatePort <= maxStartPort; candidatePort++) { + if (await canBindContiguousPorts(candidatePort, portCount, hostname)) { + return candidatePort } - - throw new Error(`Could not find ${length} contiguous available ports`) } - - describe("with real sockets", () => { - describe("isPortAvailable", () => { - it("#given unused port #when checking availability #then returns true", async () => { - const blocker = startRealBlocker() - const port = blocker.port - blocker.stop(true) - - const result = await isPortAvailable(port) - expect(result).toBe(true) - }) - - it("#given port in use #when checking availability #then returns false", async () => { - const blocker = startRealBlocker() - const port = blocker.port - - try { - const result = await isPortAvailable(port) - expect(result).toBe(false) - } finally { - blocker.stop(true) - } - }) - }) - - describe("findAvailablePort", () => { - it("#given start port available #when finding port #then returns start port", async () => { - const startPort = await findContiguousAvailableStart(1) - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort) - }) - - it("#given start port blocked #when finding port #then returns next available", async () => { - const startPort = await findContiguousAvailableStart(2) - const blocker = startRealBlocker(startPort) - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 1) - } finally { - blocker.stop(true) - } - }) - - it("#given multiple ports blocked #when finding port #then skips all blocked", async () => { - const startPort = await findContiguousAvailableStart(4) - const blockers = [ - startRealBlocker(startPort), - startRealBlocker(startPort + 1), - startRealBlocker(startPort + 2), - ] - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 3) - } finally { - blockers.forEach((blocker) => blocker.stop(true)) - } - }) - }) - - describe("getAvailableServerPort", () => { - it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => { - const preferredPort = await findContiguousAvailableStart(1) - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort) - expect(result.wasAutoSelected).toBe(false) - }) - - it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => { - const preferredPort = await findContiguousAvailableStart(2) - const blocker = startRealBlocker(preferredPort) - - try { - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort + 1) - expect(result.wasAutoSelected).toBe(true) - } finally { - blocker.stop(true) - } - }) - }) - }) - } else { - const blockedSockets = new Set() - let serveSpy: ReturnType - - function getSocketKey(port: number, hostname: string): string { - return `${hostname}:${port}` - } - - beforeEach(() => { - blockedSockets.clear() - serveSpy = spyOn(Bun, "serve").mockImplementation(({ port, hostname }) => { - if (typeof port !== "number") { - throw new Error("Test expected numeric port") - } - const resolvedHostname = typeof hostname === "string" ? hostname : HOSTNAME - const socketKey = getSocketKey(port, resolvedHostname) - - if (blockedSockets.has(socketKey)) { - const error = new Error(`Failed to start server. Is port ${port} in use?`) as Error & { - code?: string - syscall?: string - errno?: number - address?: string - port?: number - } - error.code = "EADDRINUSE" - error.syscall = "listen" - error.errno = 0 - error.address = resolvedHostname - error.port = port - throw error - } - - blockedSockets.add(socketKey) - return { - stop: (_force?: boolean) => { - blockedSockets.delete(socketKey) - }, - } as { stop: (force?: boolean) => void } - }) - }) - - afterEach(() => { - expect(blockedSockets.size).toBe(0) - serveSpy.mockRestore() - blockedSockets.clear() - }) - - describe("with mocked sockets fallback", () => { - describe("isPortAvailable", () => { - it("#given unused port #when checking availability #then returns true", async () => { - const port = 59999 - - const result = await isPortAvailable(port) - expect(result).toBe(true) - expect(blockedSockets.size).toBe(0) - }) - - it("#given port in use #when checking availability #then returns false", async () => { - const port = 59998 - const blocker = Bun.serve({ - port, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await isPortAvailable(port) - expect(result).toBe(false) - } finally { - blocker.stop(true) - } - }) - - it("#given custom hostname #when checking availability #then passes hostname through to Bun.serve", async () => { - const hostname = "192.0.2.10" - await isPortAvailable(59995, hostname) - - expect(serveSpy.mock.calls[0]?.[0]?.hostname).toBe(hostname) - }) - }) - - describe("findAvailablePort", () => { - it("#given start port available #when finding port #then returns start port", async () => { - const startPort = 59997 - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort) - }) - - it("#given start port blocked #when finding port #then returns next available", async () => { - const startPort = 59996 - const blocker = Bun.serve({ - port: startPort, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 1) - } finally { - blocker.stop(true) - } - }) - - it("#given multiple ports blocked #when finding port #then skips all blocked", async () => { - const startPort = 59993 - const blockers = [ - Bun.serve({ port: startPort, hostname: HOSTNAME, fetch: () => new Response() }), - Bun.serve({ port: startPort + 1, hostname: HOSTNAME, fetch: () => new Response() }), - Bun.serve({ port: startPort + 2, hostname: HOSTNAME, fetch: () => new Response() }), - ] - - try { - const result = await findAvailablePort(startPort) - expect(result).toBe(startPort + 3) - } finally { - blockers.forEach((blocker) => blocker.stop(true)) - } - }) - }) - - describe("getAvailableServerPort", () => { - it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => { - const preferredPort = 59990 - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort) - expect(result.wasAutoSelected).toBe(false) - }) - - it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => { - const preferredPort = 59989 - const blocker = Bun.serve({ - port: preferredPort, - hostname: HOSTNAME, - fetch: () => new Response("blocked"), - }) - - try { - const result = await getAvailableServerPort(preferredPort) - expect(result.port).toBe(preferredPort + 1) - expect(result.wasAutoSelected).toBe(true) - } finally { - blocker.stop(true) - } - }) - }) - }) } - describe("DEFAULT_SERVER_PORT", () => { - it("#given constant #when accessed #then returns 4096", () => { + throw new Error(`Could not find ${portCount} contiguous available ports`) +} + +async function startConsecutiveBlockers( + startPort: number, + portCount: number, + hostname: string = DEFAULT_HOSTNAME +): Promise { + const servers: Server[] = [] + + try { + for (let offset = 0; offset < portCount; offset++) { + servers.push(await startTrackedServer(startPort + offset, hostname)) + } + + return servers + } catch (error) { + await Promise.all(servers.map((server) => closeTrackedServer(server))) + throw error + } +} + +async function captureDefaultListenHostname(port: number): Promise { + const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen") + const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close") + let capturedHostname: string | undefined + + Object.defineProperty(Server.prototype, "listen", { + configurable: true, + value: function listenAndCaptureHostname(this: Server, requestedPort: number, hostname?: string): Server { + if (requestedPort === port) { + capturedHostname = hostname + } + queueMicrotask(() => this.emit("listening")) + return this + }, + }) + Object.defineProperty(Server.prototype, "close", { + configurable: true, + value: function closeCapturedServer(this: Server, callback?: (error?: Error) => void): Server { + queueMicrotask(() => callback?.()) + return this + }, + }) + + try { + await isPortAvailable(port) + return capturedHostname + } finally { + Object.defineProperty(Server.prototype, "listen", listenDescriptor) + Object.defineProperty(Server.prototype, "close", closeDescriptor) + } +} + +async function runTimedOutAvailabilityProbe(port: number): Promise { + const setTimeoutDescriptor = getRequiredPropertyDescriptor(globalThis, "setTimeout") + const listenDescriptor = getRequiredPropertyDescriptor(Server.prototype, "listen") + const closeDescriptor = getRequiredPropertyDescriptor(Server.prototype, "close") + const originalSetTimeout = globalThis.setTimeout + let timedOutServer: Server | undefined + let closeCallCount = 0 + + Object.defineProperty(globalThis, "setTimeout", { + configurable: true, + value: (callback: () => void): ReturnType => originalSetTimeout(callback, 0), + }) + Object.defineProperty(Server.prototype, "listen", { + configurable: true, + value: function listenWithoutEmitting(this: Server): Server { + timedOutServer = this + return this + }, + }) + Object.defineProperty(Server.prototype, "close", { + configurable: true, + value: function closeTimedOutServer(this: Server, callback?: (error?: Error) => void): Server { + closeCallCount++ + queueMicrotask(() => callback?.()) + return this + }, + }) + + try { + const isAvailable = await isPortAvailable(port) + return { closeCallCount, isAvailable, server: timedOutServer } + } finally { + Object.defineProperty(globalThis, "setTimeout", setTimeoutDescriptor) + Object.defineProperty(Server.prototype, "listen", listenDescriptor) + Object.defineProperty(Server.prototype, "close", closeDescriptor) + } +} + +describe("port-utils", () => { + beforeAll(() => { + trackedServers.clear() + }) + + afterEach(async () => { + await closeAllTrackedServers() + }) + + afterAll(async () => { + await closeAllTrackedServers() + }) + + describe("#given isPortAvailable", () => { + test("#when a released port is checked #then returns true", async () => { + const port = await getReleasedPort() + + const result = await isPortAvailable(port) + + expect(result).toBe(true) + }) + + test("#when an already bound port is checked #then returns false", async () => { + const blocker = await startTrackedServer(0) + const port = getServerPort(blocker) + + const result = await isPortAvailable(port) + + expect(result).toBe(false) + }) + + test("#when a timed out probe is cleaned up #then no listeners or server remain active", async () => { + const port = await getReleasedPort() + + const result = await runTimedOutAvailabilityProbe(port) + + expect(result.isAvailable).toBe(false) + expect(result.closeCallCount).toBe(1) + expect(result.server).toBeDefined() + if (!result.server) { + throw new Error("Expected timed out server") + } + expect(result.server.listening).toBe(false) + expect(result.server.listenerCount("error")).toBe(0) + expect(result.server.listenerCount("listening")).toBe(0) + }) + + test("#when a successful probe finishes #then the port can be rebound immediately", async () => { + const port = await getReleasedPort() + + const result = await isPortAvailable(port) + const server = await startTrackedServer(port) + + expect(result).toBe(true) + expect(getServerPort(server)).toBe(port) + }) + + test("#when hostname is omitted #then 127.0.0.1 is the default target", async () => { + const blocker = await startTrackedServer(0, DEFAULT_HOSTNAME) + const port = getServerPort(blocker) + + const result = await isPortAvailable(port) + + expect(result).toBe(false) + }) + + test("#when another interface owns the port #then default probing does not bind all interfaces", async () => { + const alternateHostname = getAlternateIpv4Hostname() + + if (!alternateHostname) { + const port = await getReleasedPort() + const capturedHostname = await captureDefaultListenHostname(port) + expect(capturedHostname).toBe(DEFAULT_HOSTNAME) + return + } + + const blocker = await startTrackedServer(0, alternateHostname) + const port = getServerPort(blocker) + + expect(await isPortAvailable(port)).toBe(true) + expect(await isPortAvailable(port, alternateHostname)).toBe(false) + }) + }) + + describe("#given findAvailablePort", () => { + test("#when the start port is available #then returns the start port", async () => { + const startPort = await findContiguousAvailableStart(1) + + const result = await findAvailablePort(startPort) + + expect(result).toBe(startPort) + }) + + test("#when the first three ports are blocked #then returns the next free port", async () => { + const startPort = await findContiguousAvailableStart(4) + await startConsecutiveBlockers(startPort, 3) + + const result = await findAvailablePort(startPort) + + expect(result).toBe(startPort + 3) + }) + + test("#when every attempted port is blocked #then throws", async () => { + const startPort = await findContiguousAvailableStart(EXHAUSTED_PORT_COUNT) + await startConsecutiveBlockers(startPort, EXHAUSTED_PORT_COUNT) + + let errorMessage: string | undefined + try { + await findAvailablePort(startPort) + } catch (error) { + if (!(error instanceof Error)) { + throw error + } + errorMessage = error.message + } + + expect(errorMessage).toBe(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`) + }) + }) + + describe("#given getAvailableServerPort", () => { + test("#when the preferred port is free #then returns the preferred port without auto-selection", async () => { + const preferredPort = await findContiguousAvailableStart(1) + + const result = await getAvailableServerPort(preferredPort) + + expect(result).toEqual({ port: preferredPort, wasAutoSelected: false }) + }) + + test("#when the preferred port is blocked #then returns the next port with auto-selection", async () => { + const preferredPort = await findContiguousAvailableStart(2) + await startTrackedServer(preferredPort) + + const result = await getAvailableServerPort(preferredPort) + expect(result).toEqual({ port: preferredPort + 1, wasAutoSelected: true }) + }) + }) + + describe("#given DEFAULT_SERVER_PORT", () => { + test("#when accessed #then returns 4096", () => { expect(DEFAULT_SERVER_PORT).toBe(4096) }) }) diff --git a/src/shared/port-utils.ts b/src/shared/port-utils.ts index 978a2658c..94a908a97 100644 --- a/src/shared/port-utils.ts +++ b/src/shared/port-utils.ts @@ -1,18 +1,53 @@ +import { createServer } from "node:net" + const DEFAULT_SERVER_PORT = 4096 const MAX_PORT_ATTEMPTS = 20 +const PORT_CHECK_TIMEOUT_MS = 2000 export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise { - try { - const server = Bun.serve({ - port, - hostname, - fetch: () => new Response(), + return new Promise((resolve) => { + const server = createServer() + let timeoutId: ReturnType | undefined + let resolved = false + + const finish = (isAvailable: boolean): void => { + if (resolved) { + return + } + resolved = true + if (timeoutId) { + clearTimeout(timeoutId) + } + server.removeAllListeners("error") + server.removeAllListeners("listening") + resolve(isAvailable) + } + + const closeThenFinish = (isAvailable: boolean): void => { + try { + server.close(() => finish(isAvailable)) + } catch { + finish(isAvailable) + } + } + + timeoutId = setTimeout(() => { + closeThenFinish(false) + }, PORT_CHECK_TIMEOUT_MS) + + server.once("error", () => { + finish(false) }) - server.stop(true) - return true - } catch { - return false - } + server.once("listening", () => { + closeThenFinish(true) + }) + + try { + server.listen(port, hostname) + } catch { + finish(false) + } + }) } export async function findAvailablePort(