Skip to content

fix: step past occupied ports and open a connectable URL - #168

Open
q0821 wants to merge 1 commit into
phuryn:mainfrom
q0821:fix/port-fallback-ipv6
Open

fix: step past occupied ports and open a connectable URL#168
q0821 wants to merge 1 commit into
phuryn:mainfrom
q0821:fix/port-fallback-ipv6

Conversation

@q0821

@q0821 q0821 commented Jul 31, 2026

Copy link
Copy Markdown

Problem

python cli.py dashboard can start successfully and still be unreachable, showing ERR_EMPTY_RESPONSE in the browser. Two independent causes, neither visible from that error.

A reverse proxy holding the port. OrbStack and Docker Desktop bind *:8080 as a wildcard. TCPServer sets allow_reuse_address, so on macOS/BSD a later bind to 127.0.0.1:8080 succeeds and coexists with it rather than raising EADDRINUSE. The proxy still wins connections on the address family it holds and answers them with nothing, which the browser reports as ERR_EMPTY_RESPONSE. Worth stressing: because bind() never fails here, a port fallback keyed on EADDRINUSE would never fire.

Handing the browser a bind address. serve() printed and opened http://localhost:PORT, but getaddrinfo returns ::1 ahead of 127.0.0.1 on macOS while ThreadingHTTPServer defaults to AF_INET and listens on IPv4 only. The browser follows ::1, misses the server, and lands on whatever holds the IPv6 wildcard, or on nothing.

Measured on macOS 15 with OrbStack running:

getaddrinfo('localhost', 8080) order:
   AF_INET6 ('::1', 8080)          <- browser takes this
   AF_INET  ('127.0.0.1', 8080)

[IPv4 127.0.0.1] 120 bytes -> b'HTTP/1.0 200 OK\r\nServer: BaseHTTP/0.6 ...'
[IPv6 ::1      ]   0 bytes -> b''      <- ERR_EMPTY_RESPONSE

Change

  • port_is_taken() probes a port by connecting to it, on both address families, since bind() cannot answer the question.
  • create_server() steps up to the next free port (10 candidates) and returns the port it actually bound.
  • browser_url() maps a bind address to something connectable. Both the printed line and the opened URL go through it, so copy-pasting from the terminal works too.
  • Fallback is limited to the interactive path. Under --no-browser the port is pinned and the server binds it or exits.
  • Removes the sleep(1.0) the browser thread used to guess at readiness: serve() reports the bound port through an on_ready callback once the socket is listening.

VS Code extension is unaffected

extension.ts:121 picks the port via resolveStablePort, :126 polls that exact port for readiness, and the webview's localStorage is keyed on that origin, so silent drift would read as a failed start. The extension always passes --no-browser, which pins the port. No TypeScript changes; server-manager.ts:75 already documents that callers handle port-collision recovery at a higher level.

Verified by simulating the extension's spawn contract:

scenario result
--no-browser --port <free> binds the requested port, /api/data returns 200
--no-browser --port <taken> exits 1 with Port N is already in use., never moves

Tests

167 pass. The headline regression test reproduces the bug exactly: with an IPv6-only squatter, a bare ThreadingHTTPServer bind succeeds, so any fallback keyed on bind failure keeps the port and leaves the browser talking to the squatter.

End-to-end with OrbStack on 8080:

Port 8080 is in use. Serving on 8081 instead.
Dashboard running at http://127.0.0.1:8081

Note: the suite emits a pre-existing ResourceWarning: unclosed socket. It reproduces on unmodified main, so it is untouched here.

The dashboard could start successfully and still be unreachable. Two
independent causes, both invisible from the error the browser shows.

A reverse proxy holding the port. OrbStack and Docker Desktop bind
*:8080 as a wildcard. TCPServer sets allow_reuse_address, so on macOS a
later bind to 127.0.0.1:8080 succeeds and coexists with it rather than
raising EADDRINUSE. The proxy still wins connections on the family it
holds and answers them with nothing, which the browser reports as
ERR_EMPTY_RESPONSE. Because bind() never fails, any port fallback keyed
on EADDRINUSE would never trigger; create_server now probes each
candidate by connecting to it, on both address families, before binding.

Handing the browser a bind address. serve() printed and opened
http://localhost:PORT, but getaddrinfo returns ::1 ahead of 127.0.0.1 on
macOS while the server listens on IPv4 only, so the browser could miss
the server entirely and land on whatever holds the IPv6 wildcard.
browser_url() maps bind addresses to somewhere actually connectable, and
both the printed line and the opened URL now go through it.

Fallback is limited to the interactive path. Under --no-browser the VS
Code extension has already picked the port, polls that exact port for
readiness, and keys the webview's localStorage on that origin, so
drifting elsewhere would read as a failed start. That path binds the
requested port or exits, as before.

Also removes the sleep(1.0) the browser thread used to guess at server
readiness: serve() now reports the bound port through an on_ready
callback once the socket is listening.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant