Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 66 additions & 51 deletions src/js/internal/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,69 +219,84 @@ yourself with Bun.serve().
},
{} as Record<string, HTMLBundle>,
);
var server: Server;
getServer: {
const enableANSIColors = Bun.enableANSIColors;

// A port somebody asked for stays fixed. Only a default (3000, bunfig `serve.port`) may move.
function portIsPinned(refusedPort: number) {
return (
// --port, --host=<host>:<port> after the entry point
port !== undefined ||
// bun --port=<port> ./index.html
process.execArgv.some(arg => arg === "--port" || arg.startsWith("--port=")) ||
// the variables Bun.serve() reads, when one of them is where the port came from
[env.BUN_PORT, env.PORT, env.NODE_PORT].some(value => value !== undefined && Number(value) === refusedPort)
);
}

function serve(port: number | undefined) {
return Bun.serve({
static: staticRoutes,
development:
env.NODE_ENV !== "production"
? {
console: enableConsoleLog,
hmr: undefined,
}
: false,

hostname,

// undefined: Bun.serve() resolves BUN_PORT/PORT/NODE_PORT, `bun --port`, bunfig `serve.port`, else 3000
port,

fetch(_req: Request) {
return new Response("Not found", { status: 404 });
},
});
}

let portInUse: number | undefined;
function listen(): Server | undefined {
try {
server = Bun.serve({
static: staticRoutes,
development:
env.NODE_ENV !== "production"
? {
console: enableConsoleLog,
hmr: undefined,
}
: false,

hostname,
port,

// use the default port via existing port detection code.
// port: 3000,

fetch(_req: Request) {
return new Response("Not found", { status: 404 });
},
});
break getServer;
return serve(port);
} catch (error: any) {
if (error?.code === "EADDRINUSE") {
let defaultPort = port || parseInt(env.PORT || env.BUN_PORT || env.NODE_PORT || "3000", 10);
for (let remainingTries = 5; remainingTries > 0; remainingTries--) {
// Anything but a refused bind is a bug worth a stack trace.
if (error?.syscall !== "listen") throw error;

let message: string = error.message;
const { code, port: refusedPort } = error;
if (code === "EADDRINUSE" && typeof refusedPort === "number" && !portIsPinned(refusedPort)) {
const lastCandidate = Math.min(refusedPort + 10, 65535);
portInUse = refusedPort;
message = `Ports ${refusedPort} to ${lastCandidate} are all in use.`;
for (let candidate = refusedPort + 1; candidate <= lastCandidate; candidate++) {
try {
server = Bun.serve({
static: staticRoutes,
development:
env.NODE_ENV !== "production"
? {
console: enableConsoleLog,
hmr: undefined,
}
: false,

hostname,

// Retry with a different port up to 4 times.
port: defaultPort++,

fetch(_req: Request) {
return new Response("Not found", { status: 404 });
},
});
break getServer;
return serve(candidate);
} catch (error: any) {
if (error?.code === "EADDRINUSE") {
continue;
if (error?.syscall !== "listen") throw error;
if (error.code !== "EADDRINUSE") {
message = error.message;
break;
}
throw error;
}
}
}

throw error;
console.error(enableANSIColors ? `\x1b[31merror\x1b[0m\x1b[2m:\x1b[0m ${message}` : `error: ${message}`);
// No process.exit(): --watch/--hot keep the process up for the next reload.
process.exitCode = 1;
return undefined;
}
}
const server = listen();
if (!server) return;

if (portInUse !== undefined) {
const message = `Port ${portInUse} is in use, using port ${server.port} instead.`;
console.warn(enableANSIColors ? `\x1b[33mwarn\x1b[0m\x1b[2m:\x1b[0m ${message}` : `warn: ${message}`);
}

const elapsed = (performance.now() - initial).toFixed(2);
const enableANSIColors = Bun.enableANSIColors;
function printInitialMessage(isFirst: boolean) {
let pathnameToPrint;
if (servePaths.length === 1) {
Expand Down
37 changes: 25 additions & 12 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1991,6 +1991,17 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
port,
hostname: _hostname,
} => {
// `err.port`, as on node's listen errors.
let with_port = |error_instance: JSValue| {
if *port != 0 {
error_instance.put(
global,
b"port",
JSValue::js_number_from_int32(i32::from(*port)),
);
}
error_instance
};
// Rust's `target_os = "linux"` excludes
// Android, so match both explicitly.
#[cfg(any(target_os = "linux", target_os = "android"))]
Expand All @@ -2011,7 +2022,7 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
syscall: bun_core::String::static_("listen"),
..Default::default()
};
let _ = global.throw_value(err.to_error_instance(global));
let _ = global.throw_value(with_port(err.to_error_instance(global)));
return;
}
// e.g. ENOSPC from epoll_ctl(EPOLL_CTL_ADD). Linux-only because
Expand All @@ -2022,20 +2033,22 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
bun_sys::Error::from_code(errno, bun_sys::Tag::listen)
.to_system_error(),
);
let _ = global.throw_value(err.to_error_instance(global));
let _ = global.throw_value(with_port(err.to_error_instance(global)));
return;
}
}
jsc::SystemError {
message: bun_core::String::create_format(format_args!(
"Failed to start server. Is port {} in use?",
port
)),
code: bun_core::String::static_("EADDRINUSE"),
syscall: bun_core::String::static_("listen"),
..Default::default()
}
.to_error_instance(global)
with_port(
jsc::SystemError {
message: bun_core::String::create_format(format_args!(
"Failed to start server. Is port {} in use?",
port
)),
code: bun_core::String::static_("EADDRINUSE"),
syscall: bun_core::String::static_("listen"),
..Default::default()
}
.to_error_instance(global),
)
}
server_config::Address::Unix(unix) => {
let unix = unix.as_bytes();
Expand Down
115 changes: 113 additions & 2 deletions test/js/bun/http/bun-serve-html-entry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Subprocess } from "bun";
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { join } from "node:path";

async function getServerUrl(process: Subprocess) {
Expand Down Expand Up @@ -715,3 +715,114 @@ test.concurrent("subdirectory routes use forward slashes on Windows", async () =
expect(buttons.status).toBe(200);
expect(await buttons.text()).toContain("<title>Buttons</title>");
});

describe("when the port is in use", () => {
// No PORT-like variable may leak in from the environment: each test picks
// exactly one way to name (or not name) the port.
const { PORT: _1, BUN_PORT: _2, NODE_PORT: _3, ...cleanEnv } = bunEnv;
const env = { ...cleanEnv, NODE_ENV: "production", NO_COLOR: "1" };
const files = { "index.html": `<!DOCTYPE html><html><body><h1>Hello</h1></body></html>` };

// Holds a port that still has room above it: the fallback walks upward and
// stops at 65535.
function takePort() {
for (;;) {
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("taken") });
if (server.port <= 65500) return server;
server.stop(true);
}
}

// Reads stdout until the dev server prints its URL or exits. The unfixed
// behaviour (serving on another port) shows up as a URL here instead of
// as a test timeout.
async function urlOrExit(proc: Subprocess<"ignore", "pipe", "pipe">) {
const decoder = new TextDecoder();
let stdout = "";
for await (const chunk of proc.stdout) {
stdout += decoder.decode(chunk, { stream: true });
// only a whole line, so a chunk boundary cannot cut the port short
const match = stdout.match(/http:\/\/\S+(?=\n)/);
if (match && URL.canParse(match[0])) return { stdout, url: new URL(match[0]) };
}
return { stdout, url: undefined };
}

test.concurrent("an explicit --port fails instead of moving to another port", async () => {
using taken = takePort();
await using dir = tempDir("html-port-taken-flag", files);
await using proc = Bun.spawn({
cmd: [bunExe(), "index.html", `--port=${taken.port}`, "--hostname=127.0.0.1"],
env,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});

const { url } = await urlOrExit(proc);
if (url) proc.kill();
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);

expect(url?.href).toBeUndefined();
expect(stderr.trim()).toBe(`error: Failed to start server. Is port ${taken.port} in use?`);
expect(exitCode).toBe(1);
});

test.concurrent("an explicit PORT fails instead of moving to another port", async () => {
using taken = takePort();
await using dir = tempDir("html-port-taken-env", files);
await using proc = Bun.spawn({
cmd: [bunExe(), "index.html", "--hostname=127.0.0.1"],
env: { ...env, PORT: String(taken.port) },
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});

const { url } = await urlOrExit(proc);
if (url) proc.kill();
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);

expect(url?.href).toBeUndefined();
expect(stderr.trim()).toBe(`error: Failed to start server. Is port ${taken.port} in use?`);
expect(exitCode).toBe(1);
});

test.concurrent("a default port moves to the next free one and says so", async () => {
using taken = takePort();
// `serve.port` replaces the built-in default of 3000, so this exercises the
// same path as a taken port 3000 without depending on that port in CI.
await using dir = tempDir("html-port-taken-default", {
...files,
"bunfig.toml": `[serve]\nport = ${taken.port}\n`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.html", "--hostname=127.0.0.1"],
env,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});

const { url, stdout } = await urlOrExit(proc);
expect(url?.href, stdout).toBeDefined();
try {
// Not on Windows: `taken` sits in the dynamic port range there, and Windows
// can hand the child's explicitly bound fallback port to a concurrent
// `port: 0` listener in another process as well (netstat then shows two
// LISTENING rows for 127.0.0.1:<port>), so a fetch may reach the wrong
// server. Real default ports (3000) are outside that range.
if (!isWindows) {
const response = await fetch(url!);
expect(response.status).toBe(200);
expect(await response.text()).toContain("<h1>Hello</h1>");
}
} finally {
proc.kill();
}
const stderr = await proc.stderr.text();

expect(Number(url!.port)).not.toBe(taken.port);
expect(stderr.trim()).toBe(`warn: Port ${taken.port} is in use, using port ${url!.port} instead.`);
});
});
23 changes: 23 additions & 0 deletions test/js/bun/http/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2673,6 +2673,29 @@ it("should support promise returned from error", async () => {
subprocess.kill();
});

it("EADDRINUSE error names the port that was refused", () => {
using first = Bun.serve({
port: 0,
hostname: "127.0.0.1",
fetch: () => new Response("first"),
});
let error: any;
try {
using second = Bun.serve({
port: first.port,
hostname: "127.0.0.1",
fetch: () => new Response("second"),
});
} catch (e) {
error = e;
}
expect({ code: error?.code, syscall: error?.syscall, port: error?.port }).toEqual({
code: "EADDRINUSE",
syscall: "listen",
port: first.port,
});
});

if (process.platform === "linux")
it("should use correct error when using a root range port(#7187)", () => {
expect(() => {
Expand Down
Loading