anomalyco / anomalyco/opencode
opencode serve: bind failures print bare "Unexpected error / ServeError" — the listen errno is never surfaced
@nexxeln is already working on this.
Since Jul 24, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
Description
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 @ f51665191a — 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 @ f51665191a, built from source — both reproduce)
Steps to reproduce
- Occupy a port:
python3 -m http.server 4096 --bind 127.0.0.1 opencode serve --hostname 127.0.0.1 --port 4096- Output is
Error: Unexpected error/ServeErrorwith 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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.