Skip to content

Memory leak: SSE connections stuck in CLOSE_WAIT cause unbounded AsyncQueue growth (~14 MB/sec) #22198

Description

@AlexZander85

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:

  1. 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.

  2. 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.

  3. 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)
  4. 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.

  5. 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). But onAbort is 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 the AsyncQueue (src/util/queue.ts) has no size limit and no backpressure.

  6. Same pattern in src/server/instance/global.ts — the streamEvents() helper is used for /event, /global/event, and /global/sync-event endpoints, all with the same unbounded AsyncQueue + onAbort-only cleanup.

What happens per zombie connection:

  • setInterval pushes heartbeat JSON every 10 seconds → never cleared
  • Bus.subscribeAll() pushes ALL server events → never unsubscribed
  • AsyncQueue grows without bound → never terminated
  • With 66+ zombie connections, that's 66× the event stream buffered with no consumer

Suggested fixes:

  1. Add size limit/backpressure to AsyncQueue (src/util/queue.ts) — drop oldest events or terminate subscription when queue exceeds threshold
  2. Add idle timeout on SSE connections — if stream.writeSSE() hasn't succeeded in N seconds, call stop() forcefully
  3. Detect write failures on the socket — if writeSSE throws or socket is in half-closed state, trigger cleanup
  4. Investigate why Hono's stream.onAbort doesn't fire on TCP half-close in Bun runtime
  5. Fix MCP child process duplication — ensure previous processes are killed before spawning new ones
  6. Enable OPENCODE_AUTO_HEAP_SNAPSHOT in 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

  1. Launch OpenCode Desktop
  2. Open a project and actively use it (chat, tool calls, LSP)
  3. Minimize/restore the window several times (triggers SSE reconnection in the Electron renderer)
  4. 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"
  5. Observe CLOSE_WAIT count growing alongside RAM — memory grows ~14 MB/sec even with stable zombie count
  6. Over hours of use, RAM reaches 24.5 GB

Screenshot and/or share link

Image

Operating System

windows 11

Terminal

No response

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions