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.
This commit is contained in:
YeonGyu-Kim
2026-05-12 12:46:31 +09:00
parent 0aafe20a85
commit 2386cbd9b9
2 changed files with 421 additions and 284 deletions
+376 -274
View File
@@ -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<Server>()
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<Server["address"]>): 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<Server> {
return new Promise<Server>((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<void> {
trackedServers.delete(server)
if (!server.listening) {
return Promise.resolve()
}
return new Promise<void>((resolve, reject) => {
server.close((error?: Error) => {
if (error) {
reject(error)
return
}
resolve()
})
server.stop(true)
})
}
async function closeAllTrackedServers(): Promise<void> {
await Promise.all(Array.from(trackedServers).map((server) => closeTrackedServer(server)))
}
async function getReleasedPort(hostname: string = DEFAULT_HOSTNAME): Promise<number> {
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<boolean> {
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<number> {
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<number> {
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<string>()
let serveSpy: ReturnType<typeof spyOn>
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<Server[]> {
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<string | undefined> {
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<TimeoutProbeResult> {
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<typeof setTimeout> => 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)
})
})
+45 -10
View File
@@ -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<boolean> {
try {
const server = Bun.serve({
port,
hostname,
fetch: () => new Response(),
return new Promise<boolean>((resolve) => {
const server = createServer()
let timeoutId: ReturnType<typeof setTimeout> | 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(