Skip to content

opencode serve: bind failures print bare "Unexpected error / ServeError" — the listen errno is never surfaced #38738

Description

@dzianisv

Description

Any bind failure in opencode serve is reported as two useless lines. The underlying errno never reaches the user:

$ opencode serve --hostname 100.68.120.26 --port 4096
Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.
Error: Unexpected error

ServeError

There is no port, no hostname, no errno, no log path. The same output appears for a busy port, an unavailable address, and a privileged port — three different user mistakes, one identical message.

Root cause

Server.listen runs Effect.runPromise(listenEffect(opts)) and lets the failure escape untouched:

packages/opencode/src/server/server.ts:73

export async function listen(opts: ListenOptions): Promise<Listener> {
  const listener = await Effect.runPromise(listenEffect(opts))

The failure is Effect's HttpServerError.ServeError, declared in effect/unstable/http/HttpServerError.js:208 as:

export class ServeError extends Data.TaggedError("ServeError") {}   // fields: { cause: unknown }

Data.TaggedError sets name = "ServeError" and leaves message empty. The real listen error is parked in .cause.

errorMessage() never looks at .cause:

packages/tui/src/util/error.ts:125

export function errorMessage(error: unknown): string {
  if (error instanceof Error) {
    if (error.message) return error.message   // "" -> falls through
    if (error.name) return error.name         // -> "ServeError"
  }

So index.ts takes the "no formatted message" branch and prints Unexpected error + the bare name.

Impact

This is not cosmetic — it makes bind failures undiagnosable. #37718 (Desktop WSL sidecar, mirrored networking) is the same two lines; the reporter had to run the port allocation sequence 8 times and read the Desktop sidecar source to guess at a cause that a single errno would have named.

Proposed fix

Two independent changes, both small:

1. Unwrap cause in errorMessage — fixes every tagged error, not just this one (packages/tui/src/util/error.ts):

if (error instanceof Error) {
  if (error.message) return error.message
  // Tagged errors (e.g. Effect's `ServeError`) carry an empty `message` and hide
  // the real failure in `cause`. Printing just the name tells the user nothing.
  if (error.cause !== undefined && error.cause !== null) {
    const inner = errorMessage(error.cause)
    if (inner && inner !== "unknown error") return error.name ? `${error.name}: ${inner}` : inner
  }
  if (error.name) return error.name
}

2. Translate bind errno into an actionable message (packages/opencode/src/server/server.ts):

export async function listen(opts: ListenOptions): Promise<Listener> {
  const listener = await Effect.runPromise(listenEffect(opts)).catch((error) => {
    throw bindError(error, opts)
  })
  ...
}

function bindError(error: unknown, opts: ListenOptions) {
  let cause: unknown = error
  const seen = new Set<unknown>()
  while (cause && typeof cause === "object" && !seen.has(cause)) {
    seen.add(cause)
    const code = (cause as { code?: unknown }).code
    const target = `${opts.hostname}:${opts.port}`
    if (code === "EADDRINUSE")
      return new Error(
        `Address ${target} is already in use. Choose a different --port, use --port 0 to pick a free one, ` +
          `or stop the process holding it (lsof -i :${opts.port}).`,
        { cause: error },
      )
    if (code === "EADDRNOTAVAIL")
      return new Error(
        `Cannot bind to ${target}: hostname "${opts.hostname}" is not an address on this machine.`,
        { cause: error },
      )
    if (code === "EACCES")
      return new Error(`Permission denied binding ${target}. Ports below 1024 require elevated privileges.`, {
        cause: error,
      })
    cause = (cause as { cause?: unknown }).cause
  }
  const detail = cause instanceof Error && cause.message ? `: ${cause.message}` : ""
  return new Error(`Failed to start server on ${opts.hostname}:${opts.port}${detail}`, { cause: error })
}

Verified on dev @ f516651 — before/after for a busy port:

before:  Error: Unexpected error \n ServeError
after:   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).

tsc --noEmit is clean on both packages. Happy to open a PR.

Note: errno-based dispatch alone is not sufficient under Bun — see the companion issue on node:http errno collapsing. But surfacing cause is correct regardless of runtime, and is a strict improvement over printing nothing.

Separately, index.ts still prefixes the real message with Error: Unexpected error. Once a message exists that preamble is misleading and should be dropped.

Plugins

None.

OpenCode version

1.17.9 (installed) and 1.18.4 (dev @ f516651, built from source — both reproduce)

Steps to reproduce

  1. Occupy a port: python3 -m http.server 4096 --bind 127.0.0.1
  2. opencode serve --hostname 127.0.0.1 --port 4096
  3. Output is Error: Unexpected error / ServeError with no indication the port is busy.

Also reproduces with any hostname not assigned to a local interface, e.g. opencode serve --hostname 100.68.120.26 --port 4096 when Tailscale is down.

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