Skip to content

fix(serve): surface the real bind error instead of "Unexpected error / ServeError"#38742

Open
dzianisv wants to merge 1 commit into
anomalyco:devfrom
dzianisv:fix/serve-bind-error-diagnostics
Open

fix(serve): surface the real bind error instead of "Unexpected error / ServeError"#38742
dzianisv wants to merge 1 commit into
anomalyco:devfrom
dzianisv:fix/serve-bind-error-diagnostics

Conversation

@dzianisv

@dzianisv dzianisv commented Jul 24, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38738
Closes #38739

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Every bind failure in opencode serve printed the same two useless lines:

$ opencode serve --hostname 100.68.120.26 --port 4096
Error: Unexpected error

ServeError

No port, no hostname, no errno. A busy port, an address that isn't on the machine, and a privileged port were indistinguishable.

There are two separate reasons for that.

The errno is discarded. Effect's HttpServerError.ServeError is a Data.TaggedError, so its message is "" and the real failure sits on .cause. errorMessage() checked message then fell straight through to name — printing the literal string ServeError. Fixed by checking cause in between. That helps every tagged error, not just this one.

Bun reports the wrong errno anyway. Under Bun, node:http listen() returns EADDRINUSE for everything. Same script, two runtimes:

                        bun 1.3.14      node
100.68.120.26:45041     EADDRINUSE      EADDRNOTAVAIL
127.0.0.1:80            EADDRINUSE      EACCES
127.0.0.1:4096          EADDRINUSE      EADDRINUSE

Bun.serve() does the same, so it isn't the node:http shim specifically. This is why unwrapping the cause alone isn't enough — the errno you unwrap is wrong. So the new assertBindable() checks a literal-IP --hostname against os.networkInterfaces() before calling listen, where the answer is unambiguous. Hostnames are skipped, since listen resolves those.

bindError() handles what's left: it walks the cause chain and maps the errno. Sub-1024 ports are reported as a privilege problem rather than a conflict, because Bun folds EACCES into EADDRINUSE and a privileged port is the far likelier explanation down there.

Finally serve raises a CliError, so the message prints on its own instead of behind Unexpected error.

bind.ts throws plain Errors and has no CLI dependency on purpose — Server.listen is also called by the Desktop/WSL sidecar and by tests.

Worth noting the Bun errno behaviour is a Bun bug and should be reported there too. This PR doesn't depend on that being fixed.

How did you verify your code works?

Drove a real serve process through every case, on dev @ f516651:

command before after
--hostname 100.68.120.26 --port 4096 (interface 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.
--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 N listens listens (unchanged)
--hostname 0.0.0.0 --port N listens listens (unchanged)
--hostname localhost --port 0 listens listens (unchanged)

Also reproduced the original bug on the released 1.17.9, not just on dev.

Tests:

  • bun test test/server/ → 306 pass, 2 skip, 0 fail
  • bun test test/cli/serve/ → 2 pass, 0 fail
  • New test/server/bind.test.ts, 13 cases — including a fixture mimicking ServeError's empty-message shape, the sub-1024 case Bun mislabels, IPv6 zone ids, and a self-referential cause chain
  • 3 new cases in packages/tui/test/util/error.test.ts for cause unwrapping, and that an explicit message still wins over cause
  • tsc --noEmit clean, oxlint 0 warnings / 0 errors on all changed files

Screenshots / recordings

Not a UI change. CLI output is in the table above.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

…/ ServeError"

Every bind failure in `opencode serve` printed the same two useless lines:

    $ opencode serve --hostname 100.68.120.26 --port 4096
    Error: Unexpected error

    ServeError

No port, no hostname, no errno. A busy port, an unavailable address, and a
privileged port were indistinguishable.

Two independent causes:

1. Effect's `HttpServerError.ServeError` is a `Data.TaggedError` whose `message`
   is empty — the real failure sits on `.cause`. `errorMessage()` never read
   `.cause`, so it fell through to `error.name` and printed "ServeError".

2. Under Bun, `node:http` `listen()` reports *every* failure as `EADDRINUSE`
   ("Failed to start server. Is port N in use?"). Node returns EADDRNOTAVAIL /
   EACCES for the same binds. So errno dispatch alone cannot tell a busy port
   from a hostname that is not on this machine.

Fixes:

- `errorMessage()` now falls back to `cause` before the bare name. This helps
  every tagged error, not just this one.
- New `server/bind.ts`. `assertBindable()` checks a literal-IP hostname against
  `os.networkInterfaces()` before binding, where an exact answer is available —
  this is what works around (2). `bindError()` walks the cause chain and
  translates the errno, treating EADDRINUSE below port 1024 as a privilege
  problem since Bun folds EACCES into it.
- `serve` raises a `CliError` so the message prints on its own rather than
  behind "Unexpected error".

Before / after:

    --hostname 100.68.120.26 --port 4096   (interface down)
      was: Unexpected error / ServeError
      now: 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       (port busy)
      was: Unexpected error / ServeError
      now: 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.

    --hostname 127.0.0.1 --port 80
      was: Unexpected error / ServeError
      now: Cannot bind 127.0.0.1:80: ports below 1024 are privileged. Use a
           port >= 1024, or run with elevated privileges.

Valid binds (0.0.0.0, 127.0.0.1, a real LAN address, --port 0) are unchanged.

Closes anomalyco#38738
Closes anomalyco#38739
Refs anomalyco#37718

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2b7v7smJX7nby2ZmsqXsp
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@dzianisv

Copy link
Copy Markdown
Author

test / typecheck / nix-eval show action_required — they need a maintainer to approve workflow runs for a first-time contributor.

Ran the same commands locally in the meantime, on dev @ f516651:

  • bun typecheck → 30/30 packages pass
  • bun turbo test → passes for opencode, tui, core, and the rest

One caveat so it isn't mistaken for fallout from this PR: @opencode-ai/app fails i18n parity > non-English locales have every English key. It fails identically on unmodified dev (671 pass, 1 fail), and this PR touches no files under packages/app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant