Skip to content

Fix memory leaks#6236

Merged
alex-alecu merged 28 commits intomainfrom
fix/memory-leak
Feb 26, 2026
Merged

Fix memory leaks#6236
alex-alecu merged 28 commits intomainfrom
fix/memory-leak

Conversation

@alex-alecu
Copy link
Contributor

@alex-alecu alex-alecu commented Feb 24, 2026

Context

Multiple issues:

  • /exit doesn't close all processes - fixed upstream by anomalyco/opencode@ad3c192
  • closing the browser tab doesn't close all processes
  • StdioClientTransport.close() from the MCP SDK closes pipes but does not kill the spawned child process, leaving orphan processes after every MCP server lifecycle

Implementation

  • Hardened TUI shutdown in packages/opencode/src/cli/cmd/tui/thread.ts so worker teardown is idempotent, does not hang on missing RPC responses, and exits cleanly on SIGHUP/SIGTERM or when the parent terminal process disappears.
  • Updated MCP lifecycle handling in packages/opencode/src/mcp/index.ts to track spawned child processes, close existing clients before reconnect/recreate, and force-kill surviving MCP children when normal close/dispose does not fully terminate them.
  • Added a full memory/orphan test suite under packages/opencode/test/memory/ plus packages/opencode/test/fixture/mcp/fake-mcp-server.js to validate disposal behavior, heap growth bounds, and orphan-process cleanup across MCP/LSP/session flows.

How to Test

Manually: Ask the CLI to plan anything, then try closing the terminal tab while watching the Activity Monitor.

From packages/opencode:

bun test test/memory/disposal.test.ts test/memory/state-leak.test.ts test/memory/lsp-lifecycle.test.ts test/memory/heap-growth.test.ts test/memory/session-heap-growth.test.ts
bun test test/memory/orphan-process.test.ts test/memory/mcp-lifecycle.test.ts

Before

Screen.Recording.2026-02-26.at.09.07.22.mov

After

Screen.Recording.2026-02-26.at.08.43.20.mov

Get in Touch

We'd love to have a way to chat with you about your changes if necessary. If you're in the Kilo Code Discord, please share your handle here.

@alex-alecu alex-alecu self-assigned this Feb 24, 2026
@mikij
Copy link
Contributor

mikij commented Feb 24, 2026

You quit a CLI and the process is still alive in memory? Because I checked that issue on OC and there people are reporting huge memory usage while CLI is running. So I see your claim is different and therefor that first question I have for you.

The shutdown RPC call to the worker thread never resolved because the worker's event loop drained after cleanup, preventing the postMessage result from being delivered back to the main thread. This left process.exit() unreachable and the process hanging indefinitely.

Replace the blocking await with a fire-and-forget shutdown RPC that waits for the worker's close event (up to 5s timeout) before calling worker.terminate().
@blacksmith-sh

This comment has been minimized.

@alex-alecu
Copy link
Contributor Author

You quit a CLI and the process is still alive in memory? Because I checked that issue on OC and there people are reporting huge memory usage while CLI is running. So I see your claim is different and therefor that first question I have for you.

@mikij We uncovered more problems than we initially suspected. Aside from the memory leak while running the CLI, we uncovered that /exit command was not actually closing the process.

@alex-alecu alex-alecu marked this pull request as ready for review February 25, 2026 11:07
@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Feb 25, 2026

Code Review Summary

Status: 1 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
packages/opencode/src/cli/cmd/tui/thread.ts 195 SIGHUP handler uses exit code 129, but standard shell convention is 128+signal (SIGHUP=1 => 129) while parent-exit path uses 0; this can mask abnormal termination paths and make lifecycle monitoring inconsistent across shutdown triggers.
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
packages/opencode/src/cli/cmd/tui/thread.ts 127 Global process-level uncaughtException/unhandledRejection listeners are added in command handler scope and never removed, which can accumulate on repeated command invocations in-process and create duplicate logging side effects.
Files Reviewed (2 files)
  • packages/opencode/src/cli/cmd/tui/thread.ts - 1 issue
  • packages/opencode/src/cli/cmd/tui/worker.ts - 0 issues

Fix these issues in Kilo Cloud

terminateWorker is registered on SIGHUP, SIGTERM, and used in onExit.
If a signal fires and then onExit triggers (or two signals arrive),
worker.terminate() and client.call('shutdown') would fire redundantly.

Cache the pending promise so subsequent calls return the same operation.
Add a comment noting that (transport as any)._process relies on a private
implementation detail of StdioClientTransport from @modelcontextprotocol/sdk@1.25.2.
Future SDK upgrades should verify this field still exists.
Previously, connect() called create() (which spawns a new child process
and writes processes[key]) before closing the existing client. The old
client's close handler would then delete processes[key], removing the
*new* process entry from the map — losing track of the new child.

Move the existing client close to before create(), matching the pattern
already used in add().
The setTimeout fallback in terminateWorker can keep the event loop open
even when the worker exits promptly, delaying shutdown paths triggered
by signals. Unref the timer so normal fast shutdown is not blocked by
this fallback.
Wrap originalClose() and child-process cleanup in try/finally so that
delete processes[key] always executes. Previously, if originalClose()
threw, the process map entry would be left stale, risking incorrect
PID references in later lifecycle operations.
Handle tab-close paths where the shell dies without forwarding SIGHUP/SIGTERM, which left `bun run ...` alive as an orphan. Add a parent-liveness watchdog in the TUI thread and route shutdown through a single idempotent `shutdownAndExit` path. Also guard the normal `process.exit(0)` path so signal/parent-exit codes are not overridden.

while (queue.length > 0) {
const pid = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(pid)], {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about Windows?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have removed the MCP related fix for now, but I left the fix for closing the terminal tab (see PR summary recordings).

What’s left should work on Windows, and the only potential limitation is reduced reliability of the parent-watchdog in restricted-permission cases (not a crash/compat break).

  • No type/runtime API mismatches introduced.
  • The changes are runtime-API based (Worker, RPC shutdown, timers, process handlers) and do not use Unix-only commands (pgrep, ps, etc.).
  • Existing Windows guard logic is still in place (win32InstallCtrlCGuard, win32DisableProcessedInput) and is untouched functionally.
  • Caveat: parent-death detection uses process.kill(parentPid, 0). On some Windows setups this can fail with permission-related errors (EPERM), in which case the watchdog intentionally does not force-exit; normal shutdown paths still work.

@alex-alecu alex-alecu merged commit 61e7fa0 into main Feb 26, 2026
10 checks passed
@alex-alecu alex-alecu deleted the fix/memory-leak branch February 26, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants