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"
healthCheckLoop → checkServerAvailable() (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:
- 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.
- Exponential backoff in
attemptTakeover() / _start() retry (5 s → 10 s → … → 5 min cap) to reduce log noise while waiting.
- 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).
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()hitsEADDRINUSE, falls intostartHealthCheckLoop(), 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
Symptoms
healthCheckLoop→checkServerAvailable()(fetch timeout) →attemptTakeover()→ bindEADDRINUSE→ loop every 5 s, forever.Root cause
The takeover mechanism introduced in #25 / PR #26 uses an HTTP probe (
fetch("/api/health")) incheckServerAvailable()to decide whether the port holder is alive. That encodes the assumption:An orphaned Windows socket breaks that assumption: it holds the port (so
bindfails withEADDRINUSE) 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:
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:
checkServerAvailable()— a half-opennet.createConnectionwith a short timeout distinguishes a live listener from kernel residue much more reliably.attemptTakeover()/_start()retry (5 s → 10 s → … → 5 min cap) to reduce log noise while waiting.bindkeeps failing withEADDRINUSEand no live HTTP server answers on the configured port, tryport+1 … port+10and 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).