Skip to content

Commit 46c866a

Browse files
committed
fix(server): detect port conflicts on wildcard hosts
1 parent 964c718 commit 46c866a

2 files changed

Lines changed: 225 additions & 0 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import http from 'node:http'
2+
import { afterEach, describe, expect, test } from 'vitest'
3+
import { createServer } from '..'
4+
import type { ViteDevServer } from '..'
5+
6+
describe('port detection', () => {
7+
let blockingServer: http.Server | null = null
8+
let viteServer: ViteDevServer | null = null
9+
10+
afterEach(async () => {
11+
if (viteServer) {
12+
await viteServer.close()
13+
viteServer = null
14+
}
15+
16+
await new Promise<void>((resolve) => {
17+
if (blockingServer) {
18+
blockingServer.close(() => resolve())
19+
blockingServer = null
20+
} else {
21+
resolve()
22+
}
23+
})
24+
})
25+
26+
describe('wildcard host detection', () => {
27+
test('detects port conflict when server listens on 0.0.0.0', async () => {
28+
const port = 15173
29+
30+
// Simulate another server (e.g., Next.js) listening on all interfaces
31+
blockingServer = http.createServer()
32+
await new Promise<void>((resolve) => {
33+
blockingServer!.listen(port, '0.0.0.0', resolve)
34+
})
35+
36+
viteServer = await createServer({
37+
root: __dirname,
38+
logLevel: 'silent',
39+
server: { port, strictPort: false, ws: false },
40+
})
41+
42+
await viteServer.listen()
43+
44+
// Vite should detect the conflict and use a different port
45+
const address = viteServer.httpServer?.address()
46+
expect(address).toBeTruthy()
47+
if (typeof address === 'object' && address) {
48+
expect(address.port).toBe(port + 1)
49+
}
50+
})
51+
52+
test('detects port conflict when server listens on :: (IPv6)', async () => {
53+
const port = 15174
54+
55+
blockingServer = http.createServer()
56+
57+
// Skip test if IPv6 is not available on this system
58+
try {
59+
await new Promise<void>((resolve, reject) => {
60+
blockingServer!.once('error', reject)
61+
blockingServer!.listen(port, '::', resolve)
62+
})
63+
} catch {
64+
return
65+
}
66+
67+
viteServer = await createServer({
68+
root: __dirname,
69+
logLevel: 'silent',
70+
server: { port, strictPort: false, ws: false },
71+
})
72+
73+
await viteServer.listen()
74+
75+
const address = viteServer.httpServer?.address()
76+
expect(address).toBeTruthy()
77+
if (typeof address === 'object' && address) {
78+
expect(address.port).toBe(port + 1)
79+
}
80+
})
81+
})
82+
83+
describe('port selection behavior', () => {
84+
test('uses requested port when available', async () => {
85+
const port = 15175
86+
87+
viteServer = await createServer({
88+
root: __dirname,
89+
logLevel: 'silent',
90+
server: { port, strictPort: true, ws: false },
91+
})
92+
93+
await viteServer.listen()
94+
95+
const address = viteServer.httpServer?.address()
96+
expect(address).toBeTruthy()
97+
if (typeof address === 'object' && address) {
98+
expect(address.port).toBe(port)
99+
}
100+
})
101+
102+
test('finds first available port when multiple ports are blocked', async () => {
103+
const basePort = 15176
104+
const blockedCount = 3
105+
const blockingServers: http.Server[] = []
106+
107+
// Block 3 consecutive ports
108+
for (let i = 0; i < blockedCount; i++) {
109+
const server = http.createServer()
110+
await new Promise<void>((resolve) => {
111+
server.listen(basePort + i, '0.0.0.0', resolve)
112+
})
113+
blockingServers.push(server)
114+
}
115+
116+
viteServer = await createServer({
117+
root: __dirname,
118+
logLevel: 'silent',
119+
server: { port: basePort, strictPort: false, ws: false },
120+
})
121+
122+
await viteServer.listen()
123+
124+
const address = viteServer.httpServer?.address()
125+
expect(address).toBeTruthy()
126+
if (typeof address === 'object' && address) {
127+
expect(address.port).toBe(basePort + blockedCount)
128+
}
129+
130+
// Cleanup additional blocking servers
131+
await Promise.all(
132+
blockingServers.map(
133+
(server) =>
134+
new Promise<void>((resolve) => server.close(() => resolve())),
135+
),
136+
)
137+
})
138+
})
139+
140+
describe('strictPort option', () => {
141+
test('throws error when port is blocked and strictPort is true', async () => {
142+
const port = 15179
143+
144+
blockingServer = http.createServer()
145+
await new Promise<void>((resolve) => {
146+
blockingServer!.listen(port, '0.0.0.0', resolve)
147+
})
148+
149+
viteServer = await createServer({
150+
root: __dirname,
151+
logLevel: 'silent',
152+
server: { port, strictPort: true, ws: false },
153+
})
154+
155+
await expect(viteServer.listen()).rejects.toThrow(
156+
`Port ${port} is already in use`,
157+
)
158+
})
159+
})
160+
161+
describe('backward compatibility', () => {
162+
test('EADDRINUSE fallback works for non-wildcard hosts', async () => {
163+
const port = 15180
164+
165+
// Server on localhost only (not detected by wildcard pre-check)
166+
blockingServer = http.createServer()
167+
await new Promise<void>((resolve) => {
168+
blockingServer!.listen(port, '127.0.0.1', resolve)
169+
})
170+
171+
// Force Vite to use the same host to trigger EADDRINUSE
172+
viteServer = await createServer({
173+
root: __dirname,
174+
logLevel: 'silent',
175+
server: { port, host: '127.0.0.1', strictPort: false, ws: false },
176+
})
177+
178+
await viteServer.listen()
179+
180+
// The existing EADDRINUSE handler should catch this
181+
const address = viteServer.httpServer?.address()
182+
expect(address).toBeTruthy()
183+
if (typeof address === 'object' && address) {
184+
expect(address.port).toBe(port + 1)
185+
}
186+
})
187+
})
188+
})

packages/vite/src/node/http.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fsp from 'node:fs/promises'
2+
import net from 'node:net'
23
import path from 'node:path'
34
import type { OutgoingHttpHeaders as HttpServerHeaders } from 'node:http'
45
import type { ServerOptions as HttpsServerOptions } from 'node:https'
@@ -7,6 +8,7 @@ import type { Connect } from '#dep-types/connect'
78
import type { ProxyOptions } from './server/middlewares/proxy'
89
import type { Logger } from './logger'
910
import type { HttpServer } from './server'
11+
import { wildcardHosts } from './constants'
1012

1113
export interface CommonServerOptions {
1214
/**
@@ -162,6 +164,30 @@ async function readFileIfExists(value?: string | Buffer | any[]) {
162164
return value
163165
}
164166

167+
/**
168+
* Check if a port is available by testing wildcard addresses.
169+
* This catches servers listening on all interfaces (0.0.0.0 or ::).
170+
*/
171+
async function isPortAvailable(port: number): Promise<boolean> {
172+
for (const host of wildcardHosts) {
173+
// Gracefully handle errors (e.g., IPv6 disabled on the system)
174+
const available = await tryListen(port, host).catch(() => true)
175+
if (!available) return false
176+
}
177+
return true
178+
}
179+
180+
function tryListen(port: number, host: string): Promise<boolean> {
181+
return new Promise((resolve) => {
182+
const server = net.createServer()
183+
server.once('error', () => resolve(false))
184+
server.once('listening', () => {
185+
server.close(() => resolve(true))
186+
})
187+
server.listen(port, host)
188+
})
189+
}
190+
165191
export async function httpServerStart(
166192
httpServer: HttpServer,
167193
serverOptions: {
@@ -173,6 +199,17 @@ export async function httpServerStart(
173199
): Promise<number> {
174200
let { port, strictPort, host, logger } = serverOptions
175201

202+
// Pre-check port availability on wildcard addresses (0.0.0.0, ::)
203+
// This catches servers listening on all interfaces that would otherwise
204+
// not trigger EADDRINUSE when binding to a specific host like localhost
205+
while (!(await isPortAvailable(port))) {
206+
if (strictPort) {
207+
throw new Error(`Port ${port} is already in use`)
208+
}
209+
logger.info(`Port ${port} is in use, trying another one...`)
210+
port++
211+
}
212+
176213
return new Promise((resolve, reject) => {
177214
const onError = (e: Error & { code?: string }) => {
178215
if (e.code === 'EADDRINUSE') {

0 commit comments

Comments
 (0)