Skip to content

[Bug] serve: preferred ports (7337/3000) not reclaimed on restart — TIME_WAIT trips a silent fallback that breaks the web UI #840

Description

@seitzbg

Describe the Bug

Repowise serve's pre-flight port check (_is_port_free in packages/cli/src/repowise/cli/commands/serve_cmd.py) does a plain socket.bind() without SO_REUSEADDR. On a restart, the just-exited instance's listener sockets on the preferred ports (default API 7337, UI 3000) are still in TIME_WAIT for ~60s, so the probe reports them "in use" and _find_free_port silently moves the API to 7338 and the UI to 3001 — even though uvicorn and Next.js both set SO_REUSEADDR and could rebind the preferred ports immediately.

Because the bundled web UI proxies to a fixed http://localhost:7337, once the API lands on 7338 every UI→API request fails with ECONNREFUSED. The result: a fully healthy server renders as "not running" — the UI loads but every panel is empty (e.g. "no docs exist for "). The port move is effectively silent (a single yellow console line; nothing obvious in the service logs), so it's easy to misread as a crash or a lost workspace.

Steps to Reproduce

  1. Start the server on its defaults, ideally under a supervisor that restarts it (e.g. a systemd unit with Restart=on-failure): repowise serve --port 7337 --ui-port 3000. Confirm it binds 7337 (API) and 3000 (UI).
  2. Restart it within ~60s (systemctl --user restart repowise-serve, or Ctrl-C and immediately re-run) so the previous sockets are still in TIME_WAIT.
  3. Watch the startup notices: Port 7337 for API server is in use — using 7338 instead. and Port 3000 for web UI is in use — using 3001 instead.
  4. Open the UI (now on 3001). It loads but all data is empty; the next-server log shows Failed to proxy http://localhost:7337/api/... Error: connect ECONNREFUSED 127.0.0.1:7337 — the frontend still targets 7337 while the API is on 7338.

Deterministic proof that the probe is stricter than the real bind (no timing needed):

import socket
from repowise.cli.commands import serve_cmd

host = "127.0.0.1"

Leave a port in TIME_WAIT the way a just-exited server does:

lis = socket.socket(); lis.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lis.bind((host, 0)); port = lis.getsockname()[1]; lis.listen(1)
cli = socket.socket(); cli.connect((host, port))
conn, _ = lis.accept()
lis.close(); conn.close(); cli.close() # server-side active close -> port in TIME_WAIT

print("_is_port_free:", serve_cmd._is_port_free(host, port)) # -> False (bug: reports busy)

But uvicorn/Next set SO_REUSEADDR and CAN bind it:

s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port)); print("real server rebind: OK") # succeeds

Expected Behavior

On restart, repowise serve should reclaim its preferred ports (7337/3000), matching what uvicorn/Next.js can actually bind, instead of silently drifting. The pre-flight probe should use the same socket options the real servers use (SO_REUSEADDR on POSIX) so a TIME_WAIT port is treated as free. A genuine live conflict (another process actively listening) should still be detected — and if a fallback is unavoidable, the UI should follow the API's actual port (or the server should fail loudly) rather than silently desyncing.

Actual Behavior

The probe reports the TIME_WAIT port busy; the API moves to 7338 and the UI to 3001. The frontend keeps proxying to localhost:7337, so the UI is up but completely non-functional until the next clean (gap > TIME_WAIT) restart. Observed logs:

repowise: Port 7337 for API server is in use — using 7338 instead.
repowise: Port 3000 for web UI is in use — using 3001 instead.
next-server: Failed to proxy http://localhost:7337/api/pages?repo_id=… Error: connect ECONNREFUSED 127.0.0.1:7337

Environment

  • OS: Ubuntu 26.04 LTS (kernel 7.0.0-22-generic)
  • Python version: 3.13.14
  • Repowise version: 0.31.0 (repowise --version)
  • Installation method: pipx

Environment

  • OS: [e.g., Windows 11, macOS 14, Ubuntu 22.04]
  • Python version: [e.g., 3.12.1]
  • Repowise version: [e.g., 0.1.2] (repowise --version)
  • Installation method: [pip, pipx, Docker]

Additional Context

Root cause — _is_port_free on main (@ v0.31.0), packages/cli/src/repowise/cli/commands/serve_cmd.py:

def _is_port_free(host: str, port: int) -> bool:
"""Return True if port can be bound on host right now."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind((host, port)) # no SO_REUSEADDR -> a TIME_WAIT port reads as busy
except OSError:
return False
return True

Suggested fix — mirror the servers' own bind semantics, guarded to POSIX since on Windows SO_REUSEADDR means "steal an active port" (which is exactly why uvicorn omits it there):

  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
      if os.name != "nt":
          sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
      try:
          sock.bind((host, port))
      except OSError:
          return False
  return True

SO_REUSEADDR only bypasses TIME_WAIT; a socket actively LISTENing still fails the bind, so real conflicts are still caught. tests/unit/cli/test_serve_port_fallback.py (from #232) is a natural home for a regression test asserting _is_port_free returns True for a TIME_WAIT port.

Possible defense-in-depth follow-up (separate from the one-line fix): have the web UI proxy to the API's actually chosen port instead of a hardcoded 7337, or make a genuine fallback loud — the fixed proxy target is what turns a harmless port bump into a silent, total UI outage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions