diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 990ce4d981b2..e81736a0f549 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -1147,6 +1147,7 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd ) { if (bsd_set_reuse(listenFd, options) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } @@ -1163,6 +1164,7 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd if (listenAddr->ai_family == AF_INET6) { int enabled = (options & LIBUS_SOCKET_IPV6_ONLY) != 0; if (setsockopt(listenFd, IPPROTO_IPV6, IPV6_V6ONLY, &enabled, sizeof(enabled)) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } } @@ -1193,6 +1195,23 @@ int bsd_set_defer_accept(LIBUS_SOCKET_DESCRIPTOR listenFd) { #endif } +#ifdef _WIN32 +#define LIBUS_ADDRINUSE WSAEADDRINUSE +#define LIBUS_ACCES WSAEACCES +#else +#define LIBUS_ADDRINUSE EADDRINUSE +#define LIBUS_ACCES EACCES +#endif + +/* bind() failures that mean "this port is owned by someone else" rather than + * "this address family is unusable on this host". The IPv6->IPv4 fallback below + * exists for the latter (EAFNOSUPPORT / EADDRNOTAVAIL when IPv6 is disabled), + * and must not swallow the former: a second listen(port, "localhost") would + * otherwise quietly bind 127.0.0.1 after ::1 returned EADDRINUSE. */ +static int bsd_bind_error_is_fatal(int err) { + return err == LIBUS_ADDRINUSE || err == LIBUS_ACCES; +} + // return LIBUS_SOCKET_ERROR or the fd that represents listen socket // listen both on ipv6 and ipv4 LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error) { @@ -1220,12 +1239,17 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int } listenAddr = a; + *error = 0; if (bsd_bind_listen_fd(listenFd, listenAddr, port, options, error) != LIBUS_SOCKET_ERROR) { freeaddrinfo(result); return listenFd; } bsd_close_socket(listenFd); + if (bsd_bind_error_is_fatal(*error)) { + freeaddrinfo(result); + return LIBUS_SOCKET_ERROR; + } } } @@ -1237,12 +1261,17 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int } listenAddr = a; + *error = 0; if (bsd_bind_listen_fd(listenFd, listenAddr, port, options, error) != LIBUS_SOCKET_ERROR) { freeaddrinfo(result); return listenFd; } bsd_close_socket(listenFd); + if (bsd_bind_error_is_fatal(*error)) { + freeaddrinfo(result); + return LIBUS_SOCKET_ERROR; + } } } diff --git a/src/js/internal/cluster/RoundRobinHandle.ts b/src/js/internal/cluster/RoundRobinHandle.ts index 6df1ef306384..ba0c2aca176d 100644 --- a/src/js/internal/cluster/RoundRobinHandle.ts +++ b/src/js/internal/cluster/RoundRobinHandle.ts @@ -37,6 +37,8 @@ export default class RoundRobinHandle { // Currently, net module only supports `ipv6Only` option in `flags`. ipv6Only: !!(flags & UV_TCP_IPV6ONLY), backlog, + // Workers bind this port directly; open the reuse group for them. + reusePort: true, }); } else this.server.listen({ diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 5125e380715f..b64118fa344c 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -4032,7 +4032,8 @@ function listenInCluster( hostname, exclusive, ipv6Only, - reusePort, + // Workers share the port via SO_REUSEPORT; matches _http_server.ts. + true /* reusePort */, readableAll, writableAll, tls, diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index a204e6d37259..6e6a0c0b2dca 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1295,6 +1295,28 @@ describe("node:http", () => { const err = await promise; expect(err.code).toBe("EADDRINUSE"); }); + + // https://github.com/oven-sh/bun/issues/15560 + test("listen(port, 'localhost') reports EADDRINUSE instead of silently binding the other loopback family", async () => { + const a = createServer(); + const b = createServer(); + try { + await new Promise((resolve, reject) => { + a.on("error", reject); + a.listen(0, "localhost", () => resolve()); + }); + const port = a.address().port; + + const err = await new Promise((resolve, reject) => { + b.on("error", resolve); + b.listen(port, "localhost", () => reject(new Error(`second listen bound ${JSON.stringify(b.address())}`))); + }); + expect(err.code).toBe("EADDRINUSE"); + } finally { + b.close(); + a.close(); + } + }); }); describe("node https server", async () => { diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index b905c7481f8f..a7e567c1f4cd 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -1870,3 +1870,25 @@ it.skipIf(isWindows)("connect({ localPort }) succeeds when the local port has TI target.close(); } }); + +// https://github.com/oven-sh/bun/issues/15560 +it('listen(port, "localhost") reports EADDRINUSE when the port is already held on localhost', async () => { + const a = createServer(); + const b = createServer(); + try { + await new Promise((resolve, reject) => { + a.on("error", reject); + a.listen(0, "localhost", resolve); + }); + const port = (a.address() as import("node:net").AddressInfo).port; + + const err = await new Promise((resolve, reject) => { + b.on("error", resolve); + b.listen(port, "localhost", () => reject(new Error(`second listen bound ${JSON.stringify(b.address())}`))); + }); + expect(err.code).toBe("EADDRINUSE"); + } finally { + b.close(); + a.close(); + } +});