Skip to content

fix(cli): prevent CLI memory leaks from oversized diffs and session accumulation - #7617

Merged
alex-alecu merged 22 commits into
mainfrom
fix/session-diff-memory-leak
Mar 26, 2026
Merged

fix(cli): prevent CLI memory leaks from oversized diffs and session accumulation#7617
alex-alecu merged 22 commits into
mainfrom
fix/session-diff-memory-leak

Conversation

@alex-alecu

@alex-alecu alex-alecu commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Why

Long-running CLI sessions could consume over 1.5 GB of memory for two reasons: huge files (like heap snapshots) were read in full and stored as diff content, and switching sessions via /new never freed the old session's data from the TUI store.

What changed

Diff size guard and cleanup

The diff engine now checks each file's byte size before reading it. If either the before or after version exceeds 256 KB, the content is skipped entirely — just like binary files are already handled. This stops oversized strings from ever entering memory. Existing sessions that already have oversized diffs stored on disk are cleaned on first load — the server-side read path detects and strips oversized content, then rewrites the file so subsequent loads are fast.

TUI store memory management

The TUI store now strips large file content at every entry point. session_diff entries have before/after removed on arrival (the sidebar only needs filenames and counts). User messages have summary.diffs emptied since the TUI never reads that field — this was a second path for the same giant strings to enter memory.

The TUI store also evicts all per-session data (messages, parts, diffs, todos, status) when navigating away from a session. Previously this data accumulated for every session visited during a TUI lifetime. The session.deleted handler also cleans up orphaned per-session data.

Known limitation: native memory retention after /new

After typing /new, the JS heap is clean — heap snapshots confirm ~103 MB with zero retained session data. However, process RSS reported by the OS may remain elevated because Bun's memory allocator (JavaScriptCore + mimalloc) does not return freed virtual memory pages to the OS within a single process lifetime. This is a known upstream issue: oven-sh/bun#28318.

In practice, most sessions should be fine — the fixes in this PR dramatically reduce peak memory by preventing oversized strings from entering the TUI store in the first place, and evicting per-session data on navigation. The native allocator retention only becomes noticeable after very large sessions (multi-GB working sets), and restarting the CLI fully reclaims all memory.

How to test

  1. Start a CLI session and use a tool to create a large file (>256 KB) in the working directory.
  2. Let the session summarize and produce a diff.
  3. Observe that the TUI stays responsive and memory usage remains stable.
  4. Confirm the sidebar still shows the correct filename, additions, and deletions for the large file.
  5. Open a session with a long conversation, then type /new to start a fresh session.
  6. Observe that memory usage does not continue to grow when switching between sessions.
  7. Switch back to the previous session and confirm it loads correctly (re-fetched from server).

When diffFull() reads file contents via git show, files exceeding 256 KB
(e.g. .heapsnapshot JSON) are now treated like binary files — before/after
are replaced with empty strings. This prevents multi-GB strings from
accumulating in downstream consumers (storage, SSE, TUI, VS Code, sharing).
Defense-in-depth: destructure away before/after content from FileDiff
objects at both TUI store entry points (SSE handler + full sync). The
sidebar only reads file, additions, deletions — carrying full file content
in the Solid store is unnecessary and risks memory bloat.
…content

Avoids allocating multi-MB strings in the JS heap for oversized files.
Previously the full content was read then discarded; now the object size
is checked first via cat-file -s and the git show is skipped entirely
when either side exceeds 256 KB.
@kilo-code-bot

kilo-code-bot Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
packages/opencode/src/session/summary.ts 123 SessionSummary.diff() reads the entire persisted session_diff JSON before scrubbing it, so the first load of an already-oversized diff still incurs the memory spike/OOM this migration is meant to prevent
packages/opencode/src/tool/task.ts 64 Child task sessions reduce permission.task to an on/off flag, so per-subagent task restrictions are lost from the persisted session permissions
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts 126 Continuing in a worktree recreates from the branch name, so local commits ahead of the remote branch are dropped
packages/kilo-vscode/script/local-bin.ts 35 CLI rebuild staleness checks only watch packages/opencode, so changes in workspace dependencies can leave bin/kilo stale
packages/kilo-vscode/src/extension.ts 382 Updating commandsToSkipShell from globalValue overwrites VS Code's built-in terminal skip-shell commands
Files Reviewed (3 files)
  • packages/opencode/src/cli/cmd/tui/context/sync.tsx - 0 new issues
  • packages/opencode/src/config/config.ts - 0 new issues
  • packages/opencode/src/kilocode/plan-followup.ts - 0 new issues

Fix these issues in Kilo Cloud


Reviewed by gpt-5.4-20260305 · 450,787 tokens

Existing sessions may have multi-GB before/after strings persisted in
session_diff JSON files. The read path in Summary.diff() now checks each
entry against the 256 KB cap and replaces oversized content with empty
strings, then rewrites the file so subsequent loads are fast. This follows
the existing unquoteGitPath migration pattern.
Comment thread packages/opencode/src/session/summary.ts Outdated
The Solid store accumulated messages, parts, diffs, todos, status, and
permissions for every session visited during a TUI lifetime. Navigating
away via /new or the session list never freed the old session's data.

Add an evict() function that deletes all per-session entries from the
store maps and clears the fullSyncedSessions cache. Wire it into:
- A createEffect in app.tsx that fires when the route changes away
  from a session (on() tracks prev vs current sessionID)
- The session.deleted SSE handler, which previously only removed the
  session list entry but left orphaned per-session data
@alex-alecu alex-alecu changed the title fix(cli): cap oversized diffs to prevent memory leak fix(cli): prevent TUI memory leaks from oversized diffs and session accumulation Mar 25, 2026
Comment thread packages/opencode/src/cli/cmd/tui/context/sync.tsx Outdated
User messages carry summary.diffs with full before/after file content
(the same giant strings as session_diff). The TUI never reads this
field. Strip it at both entry points (SSE handler + full sync) to
prevent multi-MB strings from accumulating in the Solid store.
Comment thread packages/opencode/src/cli/cmd/tui/context/sync.tsx
Bun's JSC does not return freed native heap pages to the OS within a
single Worker lifetime. After large sessions, the only way to reclaim
that 2-3 GB of native allocator retention is to terminate the worker
and spawn a fresh one.

Workaround for oven-sh/bun#28318

- Add getter-indirection layer so fetch/events transparently follow
  worker replacement without rebuilding the TUI
- Add rejectAll() to RPC client to fail in-flight calls on termination
- Add rebindable event source that re-registers handlers on new client
- Wire /new command to fire-and-forget restart; sync layer re-bootstraps
  via server.instance.disposed event from the new worker
- Guard against re-entry and skip in external server mode
Comment thread packages/opencode/src/cli/cmd/tui/thread.ts Outdated
Comment thread packages/opencode/src/cli/cmd/tui/thread.ts Outdated
…eclamation

Bun Workers are threads within the same OS process — terminate() frees
the JSC context but mimalloc retains every page process-wide, so the
previous Worker-restart approach had zero effect on RSS.

Switch to Bun.spawn() with IPC which creates a separate child process.
Killing that process returns all its native memory to the OS.

The IPC relay pattern means the RPC client persists across subprocess
restarts — event listeners, fetch proxy, and SDK all continue working
without getter-indirection or rebinding.

Workaround for oven-sh/bun#28318
Comment thread packages/opencode/src/cli/cmd/tui/thread.ts Outdated
Comment thread packages/opencode/src/cli/cmd/tui/thread.ts Outdated
The subprocess restart approach did not solve the underlying Bun native
memory retention issue (oven-sh/bun#28318). Remove all restart plumbing
from rpc.ts, thread.ts, worker.ts, app.tsx, and sdk.tsx to keep the
diff clean. The diff-size and store-eviction fixes remain.
@alex-alecu
alex-alecu force-pushed the fix/session-diff-memory-leak branch from 048d920 to 5d611d6 Compare March 26, 2026 09:41
@alex-alecu alex-alecu changed the title fix(cli): prevent TUI memory leaks from oversized diffs and session accumulation fix(cli): prevent CLI memory leaks from oversized diffs and session accumulation Mar 26, 2026
@alex-alecu alex-alecu self-assigned this Mar 26, 2026
Comment thread packages/opencode/src/cli/cmd/tui/context/sync.tsx Outdated
Comment thread packages/opencode/src/snapshot/index.ts
Comment thread packages/opencode/src/snapshot/index.ts Outdated
@@ -121,16 +121,22 @@ export namespace SessionSummary {
}),
async (input) => {
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WARNING: Existing oversized session_diff files still load fully before scrub

Storage.read() deserializes the whole JSON blob here, so a previously persisted multi-GB before/after payload is already in memory before the limit check runs. That means the "clean on first load" migration still hits the same first-load memory spike/OOM this PR is trying to prevent; only later reads benefit after the rewrite succeeds.

if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)

const allowsTask = agent.permission.some((rule) => rule.permission === "task" && rule.action === "allow")
const allowsTask = agent.permission.some((rule) => rule.permission === "task" && rule.action === "allow") // kilocode_change

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WARNING: Granular task permissions are collapsed to a boolean

This only checks whether the agent has any allowed task rule, then the child session below either gets task: false or no task restriction at all. Rules like "allow explore, deny general" are not persisted into session.permission, so a resumed task session can lose those subagent-specific limits.

async (input) => {
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
// kilocode_change start — scrub oversized diffs from stored session_diff
const limit = 256 * 1024

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why did you not use the constant here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 664ceec. Exported the constant from Snapshot and reused it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 664ceec (reuses Snapshot.MAX_DIFF_SIZE) and 1cb4b5c (broader cleanup: replaced fs.readFile with Bun.file, simplified resolveVariant params, removed destructuring in strip(), added .catch() to config read, replaced push loop with .filter(), removed if/else).

@alex-alecu
alex-alecu merged commit ab66954 into main Mar 26, 2026
13 checks passed
@alex-alecu
alex-alecu deleted the fix/session-diff-memory-leak branch March 26, 2026 12:24
kilo-code-bot Bot added a commit that referenced this pull request Mar 26, 2026
Research recent changes (Feb 26 - Mar 26, 2026) to tool definitions,
system prompts, and agent metadata that could cause performance
regressions in long-running sessions by increasing token count.

Key findings:
- New codebase_search tool (+350 tokens when experimental flag on)
- environment_details injection on every request (+15-250 tokens)
- Config paths added to system prompt (+35 tokens)
- PlanExitTool always registered (filtered by agent permissions)
- Session diff memory leak fix (PR #7617) addresses major long-session issue
jliounis pushed a commit to jliounis/kilocode that referenced this pull request May 18, 2026
…y-leak

fix(cli): prevent CLI memory leaks from oversized diffs and session accumulation
t7tran pushed a commit to t7tran/kilocode that referenced this pull request Aug 14, 2026
…y-leak

fix(cli): prevent CLI memory leaks from oversized diffs and session accumulation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants