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:
+376
-274
@@ -1,291 +1,393 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
import { createServer, Server } from "node:net"
|
||||||
import {
|
import type { AddressInfo } from "node:net"
|
||||||
isPortAvailable,
|
import { networkInterfaces } from "node:os"
|
||||||
findAvailablePort,
|
|
||||||
getAvailableServerPort,
|
|
||||||
DEFAULT_SERVER_PORT,
|
|
||||||
} from "./port-utils"
|
|
||||||
|
|
||||||
const HOSTNAME = "127.0.0.1"
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"
|
||||||
const REAL_PORT_SEARCH_WINDOW = 200
|
|
||||||
|
|
||||||
function supportsRealSocketBinding(): boolean {
|
import { DEFAULT_SERVER_PORT, findAvailablePort, getAvailableServerPort, isPortAvailable } from "./port-utils"
|
||||||
try {
|
|
||||||
const server = Bun.serve({
|
const DEFAULT_HOSTNAME = "127.0.0.1"
|
||||||
port: 0,
|
const MAX_PORT_ATTEMPTS = 20
|
||||||
hostname: HOSTNAME,
|
const EXHAUSTED_PORT_COUNT = MAX_PORT_ATTEMPTS + 1
|
||||||
fetch: () => new Response("probe"),
|
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
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
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", () => {
|
for (let candidatePort = seedPort; candidatePort <= maxStartPort; candidatePort++) {
|
||||||
if (canBindRealSockets) {
|
if (await canBindContiguousPorts(candidatePort, portCount, hostname)) {
|
||||||
function startRealBlocker(port: number = 0) {
|
return candidatePort
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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", () => {
|
throw new Error(`Could not find ${portCount} contiguous available ports`)
|
||||||
it("#given constant #when accessed #then returns 4096", () => {
|
}
|
||||||
|
|
||||||
|
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)
|
expect(DEFAULT_SERVER_PORT).toBe(4096)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+45
-10
@@ -1,18 +1,53 @@
|
|||||||
|
import { createServer } from "node:net"
|
||||||
|
|
||||||
const DEFAULT_SERVER_PORT = 4096
|
const DEFAULT_SERVER_PORT = 4096
|
||||||
const MAX_PORT_ATTEMPTS = 20
|
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> {
|
export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise<boolean> {
|
||||||
try {
|
return new Promise<boolean>((resolve) => {
|
||||||
const server = Bun.serve({
|
const server = createServer()
|
||||||
port,
|
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||||
hostname,
|
let resolved = false
|
||||||
fetch: () => new Response(),
|
|
||||||
|
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)
|
server.once("listening", () => {
|
||||||
return true
|
closeThenFinish(true)
|
||||||
} catch {
|
})
|
||||||
return false
|
|
||||||
}
|
try {
|
||||||
|
server.listen(port, hostname)
|
||||||
|
} catch {
|
||||||
|
finish(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findAvailablePort(
|
export async function findAvailablePort(
|
||||||
|
|||||||
Reference in New Issue
Block a user