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 {
|
||||
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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user