Skip to content

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

Closed as not planned

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

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    perfIndicates a performance issue or need for optimization
    on Apr 12, 2026
  2. github-actions commented on Apr 12, 2026

    @github-actions
    Contributor

    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.

  3. AlexZander85 commented on Apr 12, 2026

    @AlexZander85
    Author

    Different operating systems - different problems

  4. AlexZander85 commented on Apr 12, 2026

    @AlexZander85
    Author

    Code-level fix proposals based on source analysis:

    1. src/util/queue.ts — Add capacity limit

    Currently AsyncQueue has no size limit. Items pushed are never dropped:

    // queue.ts:5-9 — unbounded
    push(item: T) {
      const resolve = this.resolvers.shift()
      if (resolve) resolve(item)
      else this.queue.push(item)  // ← no limit
    }

    Proposed: add constructor parameter limit (default 10000). When queue exceeds limit, either drop oldest items or return false to signal overflow so the caller (SSE handler) can close the connection.

    2. src/server/instance/event.ts:46-53 — Add idle timeout

    Currently heartbeat pushes every 10s forever, even if the consumer (SSE client) is dead:

    const heartbeat = setInterval(() => {
      q.push(JSON.stringify({ type: "server.heartbeat", properties: {} }))
    }, 10_000)

    Proposed: track lastConsumed timestamp. If nothing was consumed for 60s, call stop(). Update lastConsumed after each successful stream.writeSSE().

    3. src/server/instance/event.ts:71 — Don't rely solely on onAbort

    Currently cleanup ONLY runs via stream.onAbort(stop) — which never fires when the TCP socket enters CLOSE_WAIT in Bun/Hono:

    stream.onAbort(stop)  // ← never called on TCP half-close

    Proposed: wrap stream.writeSSE() in try/catch. A write error means the socket is dead — trigger stop() immediately. This covers the CLOSE_WAIT case regardless of whether onAbort fires.

    4. src/lsp/client.ts:52 — Prune diagnostics for closed files

    Currently 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 ones

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

  5. AlexZander85 commented on Apr 12, 2026

    @AlexZander85
    Author

    Proposed code-level fixes (based on source analysis):

    1. src/util/queue.ts — Add capacity limit:

    // Current: unbounded queue — items never dropped
    push(item: T) {
      const resolve = this.resolvers.shift()
      if (resolve) resolve(item)
      else this.queue.push(item)  // ← no limit
    }
    
    // Proposed: cap queue size, signal overflow to caller
    private readonly limit: number
    constructor(limit = 10000) { this.limit = limit }
    
    push(item: T): boolean {
      const resolve = this.resolvers.shift()
      if (resolve) { resolve(item); return true }
      if (this.queue.length >= this.limit) return false  // drop or signal
      this.queue.push(item)
      return true
    }

    2. src/server/instance/event.ts:46-53 — Add idle timeout alongside heartbeat:

    // Current: heartbeat pushes every 10s forever, even if consumer is dead
    const heartbeat = setInterval(() => { q.push(...) }, 10_000)
    
    // Proposed: also check if queue is draining
    let lastConsumed = Date.now()
    const heartbeat = setInterval(() => {
      if (Date.now() - lastConsumed > 60_000) { stop(); return }  // idle 60s → kill
      q.push(...)
    }, 10_000)
    // Update lastConsumed in the for-await loop after each successful writeSSE

    3. src/server/instance/event.ts:71 — Don't rely solely on onAbort:

    // Current: cleanup ONLY via onAbort — never fires on CLOSE_WAIT
    stream.onAbort(stop)
    
    // Proposed: also wrap writeSSE in try/catch — socket error triggers cleanup
    try {
      for await (const data of q) {
        if (data === null) return
        try {
          await stream.writeSSE({ data })
          lastConsumed = Date.now()
        } catch {
          stop(); return  // write failed → socket dead → clean up
        }
      }
    } finally { stop() }

    4. src/lsp/client.ts:52 — Prune diagnostics for closed files:

    // Current: Map grows forever, only entries replaced on re-open
    const diagnostics = new Map<string, Diagnostic[]>()
    
    // Proposed: clear entries for files not in `files` map (closed files)
    // or add a periodic prune of entries older than N minutes

    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.

  6. zhumengzhu commented on Apr 17, 2026

    @zhumengzhu

    @AlexZander85 — There's now a PR that traces this to idleTimeout: 0 in adapter.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: #22552

  7. removed
    bugSomething isn't working
    perfIndicates a performance issue or need for optimization
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  8. github-actions commented on Jul 3, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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