Skip to content

Commit b0dd5a9

Browse files
Kamaikosapphi-red
andauthored
feat(dev): detect port conflicts on wildcard hosts (#21381)
Co-authored-by: sapphi-red <[email protected]>
1 parent 6864c27 commit b0dd5a9

File tree

2 files changed

+243
-20
lines changed

2 files changed

+243
-20
lines changed
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
const BASE_PORT = 15181
7+
8+
describe('port detection', () => {
9+
let blockingServer: http.Server | null = null
10+
let viteServer: ViteDevServer | null = null
11+
12+
afterEach(async () => {
13+
if (viteServer) {
14+
await viteServer.close()
15+
viteServer = null
16+
}
17+
18+
await new Promise<void>((resolve) => {
19+
if (blockingServer) {
20+
blockingServer.close(() => resolve())
21+
blockingServer = null
22+
} else {
23+
resolve()
24+
}
25+
})
26+
})
27+
28+
async function createSimpleServer(port: number, host: string) {
29+
const server = http.createServer()
30+
await new Promise<void>((resolve) => {
31+
server.listen(port, host, () => resolve())
32+
})
33+
return {
34+
[Symbol.asyncDispose]() {
35+
return new Promise<void>((resolve) => {
36+
server.close(() => resolve())
37+
})
38+
},
39+
}
40+
}
41+
42+
describe('port fallback', () => {
43+
test('detects port conflict', async () => {
44+
await using _blockingServer = await createSimpleServer(
45+
BASE_PORT,
46+
'localhost',
47+
)
48+
49+
viteServer = await createServer({
50+
root: __dirname,
51+
logLevel: 'silent',
52+
server: { port: BASE_PORT, strictPort: false, ws: false },
53+
})
54+
await viteServer.listen()
55+
56+
const address = viteServer.httpServer!.address()
57+
expect(address).toStrictEqual(
58+
expect.objectContaining({ port: BASE_PORT + 1 }),
59+
)
60+
})
61+
62+
test('detects multiple port conflict', async () => {
63+
await using _blockingServer1 = await createSimpleServer(
64+
BASE_PORT,
65+
'localhost',
66+
)
67+
await using _blockingServer2 = await createSimpleServer(
68+
BASE_PORT + 1,
69+
'localhost',
70+
)
71+
72+
viteServer = await createServer({
73+
root: __dirname,
74+
logLevel: 'silent',
75+
server: { port: BASE_PORT, strictPort: false, ws: false },
76+
})
77+
await viteServer.listen()
78+
79+
const address = viteServer.httpServer!.address()
80+
expect(address).toStrictEqual(
81+
expect.objectContaining({ port: BASE_PORT + 2 }),
82+
)
83+
})
84+
85+
test('detects port conflict when server listens on 0.0.0.0', async () => {
86+
await using _blockingServer = await createSimpleServer(
87+
BASE_PORT,
88+
'0.0.0.0',
89+
)
90+
91+
viteServer = await createServer({
92+
root: __dirname,
93+
logLevel: 'silent',
94+
server: { port: BASE_PORT, strictPort: false, ws: false },
95+
})
96+
await viteServer.listen()
97+
98+
const address = viteServer.httpServer!.address()
99+
expect(address).toStrictEqual(
100+
expect.objectContaining({ port: BASE_PORT + 1 }),
101+
)
102+
})
103+
104+
test('detects port conflict when server listens on :: (IPv6)', async (ctx) => {
105+
let blockingServer
106+
try {
107+
blockingServer = await createSimpleServer(BASE_PORT, '::')
108+
} catch {
109+
// Skip test if IPv6 is not available on this system
110+
ctx.skip()
111+
return
112+
}
113+
await using _blockingServer = blockingServer
114+
115+
viteServer = await createServer({
116+
root: __dirname,
117+
logLevel: 'silent',
118+
server: { port: BASE_PORT, strictPort: false, ws: false },
119+
})
120+
await viteServer.listen()
121+
122+
const address = viteServer.httpServer!.address()
123+
expect(address).toStrictEqual(
124+
expect.objectContaining({ port: BASE_PORT + 1 }),
125+
)
126+
})
127+
128+
test('wildcard check also runs after EADDRINUSE fallback', async () => {
129+
// localhost:n occupied
130+
// 0.0.0.0:n+1 occupied
131+
// => Vite should pick n+2
132+
133+
await using _localhostServer = await createSimpleServer(
134+
BASE_PORT,
135+
'localhost',
136+
)
137+
await using _wildcardServer = await createSimpleServer(
138+
BASE_PORT + 1,
139+
'0.0.0.0',
140+
)
141+
142+
viteServer = await createServer({
143+
root: __dirname,
144+
logLevel: 'silent',
145+
server: {
146+
port: BASE_PORT,
147+
strictPort: false,
148+
ws: false,
149+
},
150+
})
151+
await viteServer.listen()
152+
153+
const address = viteServer.httpServer!.address()
154+
expect(address).toStrictEqual(
155+
expect.objectContaining({ port: BASE_PORT + 2 }),
156+
)
157+
})
158+
})
159+
160+
test('throws error when port is blocked and strictPort is true', async () => {
161+
await using _blockingServer = await createSimpleServer(
162+
BASE_PORT,
163+
'localhost',
164+
)
165+
166+
viteServer = await createServer({
167+
root: __dirname,
168+
logLevel: 'silent',
169+
server: { port: BASE_PORT, strictPort: true, ws: false },
170+
})
171+
172+
await expect(viteServer.listen()).rejects.toThrow(
173+
`Port ${BASE_PORT} is already in use`,
174+
)
175+
})
176+
})

packages/vite/src/node/http.ts

Lines changed: 67 additions & 20 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,52 @@ async function readFileIfExists(value?: string | Buffer | any[]) {
162164
return value
163165
}
164166

167+
// Check if a port is available on wildcard addresses (0.0.0.0, ::)
168+
async function isPortAvailable(port: number): Promise<boolean> {
169+
for (const host of wildcardHosts) {
170+
// Gracefully handle errors (e.g., IPv6 disabled on the system)
171+
const available = await tryListen(port, host).catch(() => true)
172+
if (!available) return false
173+
}
174+
return true
175+
}
176+
177+
function tryListen(port: number, host: string): Promise<boolean> {
178+
return new Promise((resolve) => {
179+
const server = net.createServer()
180+
server.once('error', () => {
181+
// Ensure server is closed even on error to prevent resource leaks
182+
server.close(() => resolve(false))
183+
})
184+
server.once('listening', () => {
185+
server.close(() => resolve(true))
186+
})
187+
server.listen(port, host)
188+
})
189+
}
190+
191+
async function tryBindServer(
192+
httpServer: HttpServer,
193+
port: number,
194+
host: string | undefined,
195+
): Promise<
196+
{ success: true } | { success: false; error: Error & { code?: string } }
197+
> {
198+
return new Promise((resolve) => {
199+
const onError = (e: Error & { code?: string }) => {
200+
httpServer.removeListener('error', onError)
201+
resolve({ success: false, error: e })
202+
}
203+
httpServer.on('error', onError)
204+
httpServer.listen(port, host, () => {
205+
httpServer.removeListener('error', onError)
206+
resolve({ success: true })
207+
})
208+
})
209+
}
210+
211+
const MAX_PORT = 65535
212+
165213
export async function httpServerStart(
166214
httpServer: HttpServer,
167215
serverOptions: {
@@ -171,31 +219,30 @@ export async function httpServerStart(
171219
logger: Logger
172220
},
173221
): Promise<number> {
174-
let { port, strictPort, host, logger } = serverOptions
222+
const { port: startPort, strictPort, host, logger } = serverOptions
175223

176-
return new Promise((resolve, reject) => {
177-
const onError = (e: Error & { code?: string }) => {
178-
if (e.code === 'EADDRINUSE') {
179-
if (strictPort) {
180-
httpServer.removeListener('error', onError)
181-
reject(new Error(`Port ${port} is already in use`))
182-
} else {
183-
logger.info(`Port ${port} is in use, trying another one...`)
184-
httpServer.listen(++port, host)
185-
}
186-
} else {
187-
httpServer.removeListener('error', onError)
188-
reject(e)
224+
for (let port = startPort; port <= MAX_PORT; port++) {
225+
// Pre-check port availability on wildcard addresses (0.0.0.0, ::)
226+
// so that we avoid conflicts with other servers listening on all interfaces
227+
if (await isPortAvailable(port)) {
228+
const result = await tryBindServer(httpServer, port, host)
229+
if (result.success) {
230+
return port
231+
}
232+
if (result.error.code !== 'EADDRINUSE') {
233+
throw result.error
189234
}
190235
}
191236

192-
httpServer.on('error', onError)
237+
if (strictPort) {
238+
throw new Error(`Port ${port} is already in use`)
239+
}
193240

194-
httpServer.listen(port, host, () => {
195-
httpServer.removeListener('error', onError)
196-
resolve(port)
197-
})
198-
})
241+
logger.info(`Port ${port} is in use, trying another one...`)
242+
}
243+
throw new Error(
244+
`No available ports found between ${startPort} and ${MAX_PORT}`,
245+
)
199246
}
200247

201248
export function setClientErrorHandler(

0 commit comments

Comments
 (0)