Repository navigation
Memory leak: SSE connections stuck in CLOSE_WAIT cause unbounded AsyncQueue growth (~14 MB/sec) #22198
Description
Description
opencode-cli.exe memory consumption grows up to 24.5 GB over time. The root cause is SSE connections that get stuck in TCP CLOSE_WAIT state — the server never detects client disconnection, so cleanup never runs and unbounded AsyncQueue buffers grow indefinitely.
Evidence:
-
CLOSE_WAIT zombie connections: Measured 66-74 TCP connections stuck in CLOSE_WAIT on the server port, while only 12 or fewer are ESTABLISHED. CLOSE_WAIT means the client sent FIN (disconnected) but the server never closed its socket.
-
Memory growth rate: Measured +434 MB in 30 seconds (14.5 MB/sec) with a stable count of zombie connections — the existing zombie queues are continuously filling up.
-
Timeline of measurements:
- Start: RAM 0.96 GB, 66 CLOSE_WAIT
- 30 min later: RAM 2.5 GB, 66 CLOSE_WAIT
- 1 hour later: RAM 3.69 GB, 70 CLOSE_WAIT
- Peak observed: RAM 24.5 GB, 74 CLOSE_WAIT (from earlier session)
-
Duplicate MCP child processes: 7× docker.exe, 10× mcp-lsp-bridge.exe, 7× cmd.exe (playwright) — should be 1 each per project. These accumulate across instance re-initializations.
-
Code root cause (
src/server/instance/event.ts:71):stream.onAbort(stop)is the ONLY way cleanup runs (clearing heartbeat interval, unsubscribing from Bus events, terminating AsyncQueue). ButonAbortis never called when the TCP connection enters CLOSE_WAIT in Bun/Hono — the server-side socket remains open, the SSE stream handler keeps running, and theAsyncQueue(src/util/queue.ts) has no size limit and no backpressure. -
Same pattern in
src/server/instance/global.ts— thestreamEvents()helper is used for/event,/global/event, and/global/sync-eventendpoints, all with the same unbounded AsyncQueue + onAbort-only cleanup.
What happens per zombie connection:
setIntervalpushes heartbeat JSON every 10 seconds → never clearedBus.subscribeAll()pushes ALL server events → never unsubscribedAsyncQueuegrows without bound → never terminated- With 66+ zombie connections, that's 66× the event stream buffered with no consumer
Suggested fixes:
- Add size limit/backpressure to
AsyncQueue(src/util/queue.ts) — drop oldest events or terminate subscription when queue exceeds threshold - Add idle timeout on SSE connections — if
stream.writeSSE()hasn't succeeded in N seconds, callstop()forcefully - Detect write failures on the socket — if
writeSSEthrows or socket is in half-closed state, trigger cleanup - Investigate why Hono's
stream.onAbortdoesn't fire on TCP half-close in Bun runtime - Fix MCP child process duplication — ensure previous processes are killed before spawning new ones
- Enable
OPENCODE_AUTO_HEAP_SNAPSHOTin Desktop Electron for automated diagnostics when RSS > 2GB
Plugins
Docker MCP gateway, mcp-lsp-bridge, Playwright MCP, mcp-server-qdrant
OpenCode version
OpenCode Desktop v1.4.3
Steps to reproduce
- Launch OpenCode Desktop
- Open a project and actively use it (chat, tool calls, LSP)
- Minimize/restore the window several times (triggers SSE reconnection in the Electron renderer)
- Monitor with PowerShell:
$id = (Get-Process -Name "opencode-cli").Id $conns = netstat -ano | Select-String "$id" $cw = ($conns | Select-String "CLOSE_WAIT").Count $es = ($conns | Select-String "ESTABLISHED").Count $ram = [math]::Round((Get-Process -Id $id).WorkingSet64/1GB,2) Write-Host "RAM: ${ram}GB | Alive: $es | Zombie: $cw"
- Observe CLOSE_WAIT count growing alongside RAM — memory grows ~14 MB/sec even with stable zombie count
- Over hours of use, RAM reaches 24.5 GB
Screenshot and/or share link
Operating System
windows 11
Terminal
No response

Activity
github-actions commentedon Apr 12, 2026
This issue might be a duplicate of existing issues. Please check:
If your issue is distinct (e.g., specifically about CLOSE_WAIT TCP state + onAbort not firing in Bun/Hono), please note that in the issue so it can be tracked separately.
AlexZander85 commentedon Apr 12, 2026
Different operating systems - different problems
AlexZander85 commentedon Apr 12, 2026
Code-level fix proposals based on source analysis:
1.
src/util/queue.ts— Add capacity limitCurrently
AsyncQueuehas no size limit. Items pushed are never dropped:Proposed: add constructor parameter
limit(default 10000). When queue exceeds limit, either drop oldest items or returnfalseto signal overflow so the caller (SSE handler) can close the connection.2.
src/server/instance/event.ts:46-53— Add idle timeoutCurrently heartbeat pushes every 10s forever, even if the consumer (SSE client) is dead:
Proposed: track
lastConsumedtimestamp. If nothing was consumed for 60s, callstop(). UpdatelastConsumedafter each successfulstream.writeSSE().3.
src/server/instance/event.ts:71— Don't rely solely ononAbortCurrently cleanup ONLY runs via
stream.onAbort(stop)— which never fires when the TCP socket enters CLOSE_WAIT in Bun/Hono:Proposed: wrap
stream.writeSSE()in try/catch. A write error means the socket is dead — triggerstop()immediately. This covers the CLOSE_WAIT case regardless of whetheronAbortfires.4.
src/lsp/client.ts:52— Prune diagnostics for closed filesCurrently
diagnostics = new Map<string, Diagnostic[]>()grows unbounded. Entries are only replaced on file re-open (diagnostics.delete(input.path)at line 194, then re-add). Files that are closed and never re-opened stay forever. For TypeScript language server reporting diagnostics on 1000+ files, this accumulates significantly over time.5.
src/mcp/index.ts— Kill previous MCP processes before spawning new onesObserved 7-10× duplicate MCP process sets (docker, mcp-lsp-bridge, playwright). The
closeClient()function at line 548 exists but is apparently not called when instances are re-initialized. Instance disposal should properly close all MCP clients before new ones are created.AlexZander85 commentedon Apr 12, 2026
Proposed code-level fixes (based on source analysis):
1.
src/util/queue.ts— Add capacity limit:2.
src/server/instance/event.ts:46-53— Add idle timeout alongside heartbeat:3.
src/server/instance/event.ts:71— Don't rely solely ononAbort:4.
src/lsp/client.ts:52— Prune diagnostics for closed files:5.
src/mcp/index.ts— Kill previous MCP processes before spawning new ones:The 7-10× duplicate process sets suggest that
closeClient()(line 548) is not being called when instances are re-initialized. Check that instance disposal properly closes all MCP clients before new ones are created.zhumengzhu commentedon Apr 17, 2026
@AlexZander85 — There's now a PR that traces this to
idleTimeout: 0inadapter.bun.ts(Bun's idle timeout is completely disabled in production, so zombie connections are never cleaned up at the transport layer). The fix restores it to 120s. Details: #22552docs(research): desktop-shell architecture research from feat/desktop…
github-actions commentedon Jul 3, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.