Description
Under Bun, node:http's server.listen() reports every failure as EADDRINUSE with the message Failed to start server. Is port N in use?. The real errno is discarded. opencode serve runs on Bun and binds through NodeHttpServer.layer(() => createServer(), ...) (packages/opencode/src/server/server.ts), so it inherits this — errno-based error handling in opencode cannot tell a busy port from a bad --hostname.
Same script, two runtimes:
import { createServer } from "node:http"
for (const [host, port] of [["100.68.120.26", 45041], ["127.0.0.1", 80], ["127.0.0.1", 4096]]) {
await new Promise((res) => {
const s = createServer(() => {})
s.on("error", (e) => { console.log(`${host}:${port} -> ${e.code} ${e.message}`); res() })
s.listen({ host, port }, () => { console.log(`${host}:${port} -> LISTENING`); s.close(); res() })
})
}
$ bun probe.mjs
100.68.120.26:45041 -> EADDRINUSE Failed to start server. Is port 45041 in use? <-- wrong, address not available
127.0.0.1:80 -> EADDRINUSE Failed to start server. Is port 80 in use? <-- wrong, permission denied
127.0.0.1:4096 -> EADDRINUSE Failed to start server. Is port 4096 in use? <-- correct
$ node probe.mjs
100.68.120.26:45041 -> EADDRNOTAVAIL listen EADDRNOTAVAIL: address not available 100.68.120.26:45041
127.0.0.1:80 -> EACCES listen EACCES: permission denied 127.0.0.1:80
127.0.0.1:4096 -> EADDRINUSE listen EADDRINUSE: address already in use 127.0.0.1:4096
Bun.serve() behaves the same way (code: "EADDRINUSE" for an unavailable address), so this is not specific to the node:http shim.
Why this matters for opencode
opencode serve --hostname <ip> is the documented path for exposing the server on a LAN, Tailscale, or VPN address. The interface is frequently down when the user runs the command. The correct diagnosis is "that address does not exist on this machine, bring the interface up" — Bun's errno instead sends the user to hunt for a nonexistent port conflict. Bun 1.3.14, macOS 15.5.
This is a Bun defect, but opencode pins Bun as its runtime, so opencode users hit it and opencode has to defend against it.
Proposed fix
Validate the address before handing it to listen(), where an exact answer is available from os.networkInterfaces():
import { isIP } from "node:net"
import { networkInterfaces } from "node:os"
const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "localhost", "127.0.0.1", "::1"])
/**
* Bun's `node:http` shim reports every `listen()` failure as `EADDRINUSE` with the
* message "Is port N in use?", so a bad `--hostname` is indistinguishable from a
* taken port at the error site. Check the address up front, where we can be exact.
*/
function assertBindable(opts: ListenOptions) {
const hostname = opts.hostname
if (WILDCARD_HOSTS.has(hostname)) return
// A DNS name is resolved by listen(); only literal IPs can be checked here.
if (!isIP(hostname)) return
const local = Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.map((entry) => entry.address)
if (local.includes(hostname)) return
throw new Error(
`Cannot bind to ${hostname}: it is not an address on any local network interface.` +
` Available: ${[...new Set(local)].join(", ")}.` +
` Use 0.0.0.0 to listen on every interface, or bring the interface up first (e.g. start Tailscale/VPN).`,
)
}
export async function listen(opts: ListenOptions): Promise<Listener> {
assertBindable(opts)
...
}
Privileged ports can be split off in the EADDRINUSE branch, since Bun collapses EACCES into it:
if (code === "EADDRINUSE") {
if (opts.port !== 0 && opts.port < 1024)
return new Error(
`Cannot bind ${target}: ports below 1024 are privileged. Use a port >= 1024, or run with elevated privileges.`,
{ cause: error },
)
return new Error(`Address ${target} is already in use. ...`, { cause: error })
}
Verified on dev @ f516651, all five cases:
| command |
before |
after |
--hostname 100.68.120.26 --port 4096 (Tailscale down) |
Unexpected error / ServeError |
Cannot bind to 100.68.120.26: it is not an address on any local network interface. Available: 127.0.0.1, ::1, 192.168.0.40, … Use 0.0.0.0 to listen on every interface, or bring the interface up first (e.g. start Tailscale/VPN). |
--hostname 127.0.0.1 --port 4096 (busy) |
Unexpected error / ServeError |
Address 127.0.0.1:4096 is already in use. Choose a different --port, use --port 0 to pick a free one, or stop the process holding it (lsof -i :4096). |
--hostname 127.0.0.1 --port 80 |
Unexpected error / ServeError |
Cannot bind 127.0.0.1:80: ports below 1024 are privileged. Use a port >= 1024, or run with elevated privileges. |
--hostname 192.168.0.40 --port 45051 |
listens |
listens (no regression) |
--hostname 0.0.0.0 --port 45052 |
listens |
listens (no regression) |
tsc --noEmit clean. Happy to open a PR covering this and #38738 together.
Worth reporting the errno collapsing upstream to oven-sh/bun as well, but opencode should not wait on that fix.
Plugins
None.
OpenCode version
1.17.9 (installed) and 1.18.4 (dev @ f516651, built from source — both reproduce)
Steps to reproduce
- Pick an IP not assigned to any local interface (e.g. a Tailscale
100.64.0.0/10 address while Tailscale is stopped).
opencode serve --hostname 100.68.120.26 --port 45041
- Fails. Under the hood the errno is
EADDRINUSE even though port 45041 is free — confirmed with lsof -i :45041 returning nothing.
Operating System
macOS 15.5 (arm64), Bun 1.3.14
Terminal
Ghostty
Description
Under Bun,
node:http'sserver.listen()reports every failure asEADDRINUSEwith the messageFailed to start server. Is port N in use?. The real errno is discarded.opencode serveruns on Bun and binds throughNodeHttpServer.layer(() => createServer(), ...)(packages/opencode/src/server/server.ts), so it inherits this — errno-based error handling in opencode cannot tell a busy port from a bad--hostname.Same script, two runtimes:
Bun.serve()behaves the same way (code: "EADDRINUSE"for an unavailable address), so this is not specific to thenode:httpshim.Why this matters for opencode
opencode serve --hostname <ip>is the documented path for exposing the server on a LAN, Tailscale, or VPN address. The interface is frequently down when the user runs the command. The correct diagnosis is "that address does not exist on this machine, bring the interface up" — Bun's errno instead sends the user to hunt for a nonexistent port conflict. Bun 1.3.14, macOS 15.5.This is a Bun defect, but opencode pins Bun as its runtime, so opencode users hit it and opencode has to defend against it.
Proposed fix
Validate the address before handing it to
listen(), where an exact answer is available fromos.networkInterfaces():Privileged ports can be split off in the
EADDRINUSEbranch, since Bun collapsesEACCESinto it:Verified on
dev@ f516651, all five cases:--hostname 100.68.120.26 --port 4096(Tailscale down)Unexpected error/ServeErrorCannot bind to 100.68.120.26: it is not an address on any local network interface. Available: 127.0.0.1, ::1, 192.168.0.40, … Use 0.0.0.0 to listen on every interface, or bring the interface up first (e.g. start Tailscale/VPN).--hostname 127.0.0.1 --port 4096(busy)Unexpected error/ServeErrorAddress 127.0.0.1:4096 is already in use. Choose a different --port, use --port 0 to pick a free one, or stop the process holding it (lsof -i :4096).--hostname 127.0.0.1 --port 80Unexpected error/ServeErrorCannot bind 127.0.0.1:80: ports below 1024 are privileged. Use a port >= 1024, or run with elevated privileges.--hostname 192.168.0.40 --port 45051--hostname 0.0.0.0 --port 45052tsc --noEmitclean. Happy to open a PR covering this and #38738 together.Worth reporting the errno collapsing upstream to oven-sh/bun as well, but opencode should not wait on that fix.
Plugins
None.
OpenCode version
1.17.9 (installed) and 1.18.4 (
dev@ f516651, built from source — both reproduce)Steps to reproduce
100.64.0.0/10address while Tailscale is stopped).opencode serve --hostname 100.68.120.26 --port 45041EADDRINUSEeven though port 45041 is free — confirmed withlsof -i :45041returning nothing.Operating System
macOS 15.5 (arm64), Bun 1.3.14
Terminal
Ghostty