[PR #13183] fix: resolve memory leaks and zombie processes from missing cleanup handlers #14546

Closed
opened 2026-02-16 18:19:20 -05:00 by yindo · 0 comments
Owner

Original Pull Request: https://github.com/anomalyco/opencode/pull/13183

State: closed
Merged: No


Summary

Fixes critical memory leaks and zombie process accumulation that cause kernel panics on macOS. Users with 16GB MacBook Pros report 10+ zombie opencode processes consuming ~9.4GB, requiring forced reboots every few days.

Closes #12687

Root Causes Addressed

  1. No signal handlersindex.ts and worker.ts had no SIGTERM/SIGINT/SIGHUP handlers. When a terminal is closed (SIGHUP), all child processes (LSP servers, MCP servers, bash tool processes spawned with detached: true) are orphaned as zombies. This is the primary cause.

  2. Unreachable cleanup code in serve.tsawait new Promise(() => {}) blocks forever, making server.stop() dead code.

  3. O(n²) string concatenation in bash tool and prompt shelloutput += chunk.toString() causes massive transient memory spikes for verbose commands, plus Buffer.concat(chunks).toString() was being called on every data event for TUI metadata preview.

  4. Deep clone of entire message historyclone(msgs) from remeda deep-copies all messages (including large tool outputs) every prompt loop iteration just to wrap text in <system-reminder> tags.

  5. Missing dispose callbacksPermissionNext and Question modules register state without dispose callbacks. Pending promise closures (holding references to resolve/reject functions and their associated data) leak on instance disposal.

  6. Unbounded FileTime dataFileTime read records and locks grow without bound and have no cleanup on instance disposal.

Changes (8 files)

Fix 1: Graceful shutdown signal handlers (index.ts, worker.ts, serve.ts)

  • Added SIGTERM/SIGINT/SIGHUP handlers that call Instance.disposeAll() before exit
  • Added Instance.disposeAll() to the finally block in index.ts
  • Replaced unreachable await new Promise(() => {}) in serve.ts with signal-based exit + Instance.disposeAll() + server.stop()

Fix 2: Bash/shell output ring buffer (bash.ts, prompt.ts)

  • Replaced output += chunk.toString() with Buffer[] array accumulation
  • Capped at 10MB — oldest chunks are evicted when limit is exceeded
  • Added a separate preview string (capped at 30KB) for TUI metadata display, avoiding Buffer.concat() on every data event
  • Final output joined with Buffer.concat(chunks).toString() only at end

Fix 3: Shallow copy instead of deep clone (prompt.ts)

  • Replaced clone(msgs) with msgs.map(msg => ({ ...msg, parts: msg.parts.map(part => ({ ...part })) }))
  • Removed unused import { clone } from "remeda"

Fix 4: Missing dispose callbacks (permission/next.ts, question/index.ts)

  • Added dispose callbacks that reject all pending promises with RejectedError
  • Matches the pattern already used in the legacy Permission module

Fix 5: FileTime cleanup (file/time.ts)

  • Added dispose callback to FileTime.state that clears read records and locks map

Testing

  • tsgo --noEmit passes across all 17 packages (pre-push hook verified)
  • bun test runs 844 tests across 59 files with 0 failures
  • All changes follow existing patterns (e.g., Instance.state(init, dispose?) lifecycle, RejectedError for pending promise rejection)

Related Issues/PRs

Multiple community PRs address overlapping subsets of this problem: #12053, #10392, #12259, #9693, #9146, #9147, #7914. This PR provides a comprehensive fix covering all identified root causes.

**Original Pull Request:** https://github.com/anomalyco/opencode/pull/13183 **State:** closed **Merged:** No --- ## Summary Fixes critical memory leaks and zombie process accumulation that cause kernel panics on macOS. Users with 16GB MacBook Pros report 10+ zombie opencode processes consuming ~9.4GB, requiring forced reboots every few days. Closes #12687 ## Root Causes Addressed 1. **No signal handlers** — `index.ts` and `worker.ts` had no `SIGTERM`/`SIGINT`/`SIGHUP` handlers. When a terminal is closed (SIGHUP), all child processes (LSP servers, MCP servers, bash tool processes spawned with `detached: true`) are orphaned as zombies. This is the primary cause. 2. **Unreachable cleanup code in `serve.ts`** — `await new Promise(() => {})` blocks forever, making `server.stop()` dead code. 3. **O(n²) string concatenation in bash tool and prompt shell** — `output += chunk.toString()` causes massive transient memory spikes for verbose commands, plus `Buffer.concat(chunks).toString()` was being called on every data event for TUI metadata preview. 4. **Deep clone of entire message history** — `clone(msgs)` from remeda deep-copies all messages (including large tool outputs) every prompt loop iteration just to wrap text in `<system-reminder>` tags. 5. **Missing dispose callbacks** — `PermissionNext` and `Question` modules register state without dispose callbacks. Pending promise closures (holding references to resolve/reject functions and their associated data) leak on instance disposal. 6. **Unbounded FileTime data** — `FileTime` read records and locks grow without bound and have no cleanup on instance disposal. ## Changes (8 files) ### Fix 1: Graceful shutdown signal handlers (`index.ts`, `worker.ts`, `serve.ts`) - Added `SIGTERM`/`SIGINT`/`SIGHUP` handlers that call `Instance.disposeAll()` before exit - Added `Instance.disposeAll()` to the `finally` block in `index.ts` - Replaced unreachable `await new Promise(() => {})` in `serve.ts` with signal-based exit + `Instance.disposeAll()` + `server.stop()` ### Fix 2: Bash/shell output ring buffer (`bash.ts`, `prompt.ts`) - Replaced `output += chunk.toString()` with `Buffer[]` array accumulation - Capped at 10MB — oldest chunks are evicted when limit is exceeded - Added a separate `preview` string (capped at 30KB) for TUI metadata display, avoiding `Buffer.concat()` on every data event - Final output joined with `Buffer.concat(chunks).toString()` only at end ### Fix 3: Shallow copy instead of deep clone (`prompt.ts`) - Replaced `clone(msgs)` with `msgs.map(msg => ({ ...msg, parts: msg.parts.map(part => ({ ...part })) }))` - Removed unused `import { clone } from "remeda"` ### Fix 4: Missing dispose callbacks (`permission/next.ts`, `question/index.ts`) - Added dispose callbacks that reject all pending promises with `RejectedError` - Matches the pattern already used in the legacy `Permission` module ### Fix 5: FileTime cleanup (`file/time.ts`) - Added dispose callback to `FileTime.state` that clears `read` records and `locks` map ## Testing - `tsgo --noEmit` passes across all 17 packages (pre-push hook verified) - `bun test` runs 844 tests across 59 files with 0 failures - All changes follow existing patterns (e.g., `Instance.state(init, dispose?)` lifecycle, `RejectedError` for pending promise rejection) ## Related Issues/PRs Multiple community PRs address overlapping subsets of this problem: #12053, #10392, #12259, #9693, #9146, #9147, #7914. This PR provides a comprehensive fix covering all identified root causes.
yindo added the pull-request label 2026-02-16 18:19:20 -05:00
yindo closed this issue 2026-02-16 18:19:20 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#14546