Skip to content

opencode serve: Bun collapses every listen() errno to EADDRINUSE, so a bad --hostname is misdiagnosed as a port conflict #38739

Description

@dzianisv

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

  1. Pick an IP not assigned to any local interface (e.g. a Tailscale 100.64.0.0/10 address while Tailscale is stopped).
  2. opencode serve --hostname 100.68.120.26 --port 45041
  3. 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions