Skip to content

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

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

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 <[email protected]>
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.

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

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

Projects

None yet

1 participant