Skip to content

[Bug] Web server takeover loop on Windows when orphaned LISTEN socket holds the port #251

Description

@skywalker-35

Summary

On Windows, when the opencode process dies abnormally (crash / killed), its web server socket can remain in the kernel TCP table as an orphaned LISTEN socket (the owning PID no longer exists). On the next start, WebServer._start() hits EADDRINUSE, falls into startHealthCheckLoop(), and gets stuck in a takeover death loop — the web UI never comes back until Windows reclaims the socket (observed: 2–4 hours) or the machine reboots.

Environment

  • OS: Windows 11
  • opencode-mem: 2.24.3
  • Runtime: Bun (bundled with opencode)

Symptoms

netstat -ano                     → TCP 127.0.0.1:4748 LISTENING PID 20748
Get-Process -Id 20748            → (nothing — the process is dead)
Test-NetConnection :4748         → TcpTestSucceeded: False
Invoke-WebRequest :4748          → connection refused
opencode-mem log                 → MCP backend works fine (auto-capture keeps
                                   persisting), but zero web-server entries after
                                   "Embedding model warmed up"

healthCheckLoopcheckServerAvailable() (fetch timeout) → attemptTakeover() → bind EADDRINUSE → loop every 5 s, forever.

Root cause

The takeover mechanism introduced in #25 / PR #26 uses an HTTP probe (fetch("/api/health")) in checkServerAvailable() to decide whether the port holder is alive. That encodes the assumption:

port occupied = another healthy opencode instance owns it

An orphaned Windows socket breaks that assumption: it holds the port (so bind fails with EADDRINUSE) but nothing answers HTTP (so the fetch always fails). Result: probe says "not available" → takeover fires → bind fails → loop.

There are actually two flavors of orphaned socket on Windows:

Type TCP connect HTTP fetch bind Origin
A (like #32) ✅ accepted ❌ never responds EADDRINUSE Bun worker thread residue
B ❌ RST (refused) ❌ refused EADDRINUSE Kernel TCP-table residue after abnormal process exit

PR #26 fixed takeover but probes via HTTP only; PR #33 fixed the Bun worker but doesn't cover orphaned sockets. Together they leave this Windows-specific gap.

Suggested fixes

Any of these would break the loop; I've been running (1)+(3) locally as a patch and it fully resolves the issue:

  1. Probe with TCP connect instead of (or before) HTTP fetch in checkServerAvailable() — a half-open net.createConnection with a short timeout distinguishes a live listener from kernel residue much more reliably.
  2. Exponential backoff in attemptTakeover() / _start() retry (5 s → 10 s → … → 5 min cap) to reduce log noise while waiting.
  3. Auto-increment port: when bind keeps failing with EADDRINUSE and no live HTTP server answers on the configured port, try port+1 … port+10 and bind the first free one, then surface the actual port via toast/log. This makes both orphan flavors harmless — the web UI is immediately available on a neighbor port and self-heals back to the configured port once Windows reclaims the socket.

Happy to share my local patch (≈50 lines against dist/services/web-server.js) or turn it into a PR against the source if you're interested.

Possibly related: #25 (takeover), #32 (Windows Bun worker), #168 (Windows crash).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions