diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 0f06a2cf..3993ed7a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -278,8 +278,8 @@ function resolveHost(input: string | undefined): string { return trimmed } -function programHasArg(argv: string[], flag: string): boolean { - return argv.includes(flag) +export function programHasArg(argv: string[], flag: string): boolean { + return argv.some((argument) => argument === flag || argument.startsWith(`${flag}=`)) } async function main() { diff --git a/packages/server/src/server/__tests__/listener-retry.test.ts b/packages/server/src/server/__tests__/listener-retry.test.ts new file mode 100644 index 00000000..6da41b81 --- /dev/null +++ b/packages/server/src/server/__tests__/listener-retry.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { programHasArg } from "../../index" +import { shouldRetryPreferredPort } from "../http-server" + +test("automatic listeners retry Windows reserved ports without masking explicit failures", () => { + assert.equal(shouldRetryPreferredPort({ code: "EADDRINUSE" }, true, "linux"), true) + assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, true, "win32"), true) + assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, true, "linux"), false) + assert.equal(shouldRetryPreferredPort({ code: "EACCES" }, false, "win32"), false) +}) + +test("explicit listener ports are detected in both supported CLI forms", () => { + assert.equal(programHasArg(["--http-port", "9899"], "--http-port"), true) + assert.equal(programHasArg(["--https-port=9898"], "--https-port"), true) + assert.equal(programHasArg(["--http-porter=9899"], "--http-port"), false) +}) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 8df2832b..44e855b2 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -82,6 +82,12 @@ interface HttpServerStartResult { displayHost: string } +export function shouldRetryPreferredPort(error: unknown, autoPortRequested: boolean, platform = process.platform): boolean { + if (!autoPortRequested) return false + const code = (error as NodeJS.ErrnoException | undefined)?.code + return code === "EADDRINUSE" || (platform === "win32" && code === "EACCES") +} + export function createHttpServer(deps: HttpServerDeps) { // Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS. // We keep the runtime behavior correct and cast the instance to a generic FastifyInstance. @@ -346,18 +352,12 @@ export function createHttpServer(deps: HttpServerDeps) { const autoPortRequested = deps.bindPort === 0 const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort - const shouldRetryWithEphemeral = (error: unknown) => { - if (!autoPortRequested) return false - const err = error as NodeJS.ErrnoException | undefined - return Boolean(err && err.code === "EADDRINUSE") - } - let listenResult try { listenResult = await attemptListen(primaryPort) } catch (error) { - if (!shouldRetryWithEphemeral(error)) { + if (!shouldRetryPreferredPort(error, autoPortRequested)) { throw error } deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")