You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG/PERF] Severe Memory Leak and Disk Swell leading to System Kernel Panic (macOS) #12687
I am encountering a critical performance issue when using OpenCode at scale. While the TypeScript implementation is impressive, the current resource management leads to total system instability on macOS. After a few hours of intensive use, the system undergoes a forced restart due to memory exhaustion.
Observed Behavior
RAM/Swap Exhaustion: OpenCode consumes all available physical RAM and then expands into Swap until macOS triggers a kernel panic or forced restart.
Disk Swell: Approximately 50GB of disk space is consumed within a few hours. This space is reclaimed immediately after a restart, suggesting it is either a ballooning swapfile or unmanaged session storage.
System Impact: Daily mandatory restarts of the host machine (MacBook).
Investigation Plan
I am treating this as a research-based issue rather than a standard bug report. My next steps include:
Process Monitoring: Identifying if the leak is in the main process, a specific worker, or the underlying TUI/Language Server.
Storage Audit: Locating the specific directory where the 50GB swell occurs (e.g., ~/Library/Application Support/OpenCode or /private/var/vm).
Heap Snapshots: Attempting to capture V8 heap snapshots before the system freezes.
Forensic Analysis: Kernel Panic Root Cause (Feb 11, 2026)
Machine: MacBook Pro M1 (MacBookPro17,1), 16 GB RAM, macOS 15.5 (24F74), ~30 GB free disk
Kernel Panic Details
Two kernel panics in 3 days, both identical cause:
Feb 11, 01:47 AM:
panic(cpu 5): watchdog timeout: no checkins from watchdogd in 91 seconds
Compressor Info: 54% of compressed pages limit (OK) and 70% of segments limit (OK)
with 18 swapfiles and LOW swap space
Feb 9, 00:28 AM:
panic(cpu 0): watchdog timeout: no checkins from watchdogd in 91 seconds
Compressor Info: 52% of compressed pages limit (OK) and 95% of segments limit (OK)
with 23 swapfiles and LOW swap space
Both times the system was so memory-starved that the kernel watchdog daemon couldn't check in within 91 seconds, triggering a hard panic and forced reboot. Boot faults recorded as wdog,reset_in_1 timeout.
Jetsam (OOM) Events Confirm opencode as Primary Consumer
The macOS Jetsam event from Feb 10 explicitly identifies:
"largestProcess": "opencode"
Memory snapshot at time of Jetsam kill:
Process
Resident Memory
PID
opencode
1,525.9 MB
10748
opencode
1,350.3 MB
38850
opencode
1,325.4 MB
78124
opencode
1,211.9 MB
8579
opencode
1,103.5 MB
46921
opencode
789.1 MB
11105
opencode
731.6 MB
60304
opencode
465.1 MB
35103
opencode
447.3 MB
26047
opencode
422.6 MB
59824
Total (opencode only)
~9.4 GB
10 instances
node (2 instances)
~1.1 GB
—
10 opencode processes consuming ~9.4 GB on a 16 GB machine. That's 59% of total physical RAM from opencode alone.
System memory state at time of Jetsam:
Free pages: 13,781 (~215 MB) — critically low
Active: 221,716 pages (~3.4 GB)
Inactive: 210,697 pages (~3.3 GB)
Wired: 129,341 pages (~2.0 GB)
Compressor size: 437,326 pages (~6.8 GB)
Evidence of Memory Leak
The opencode diagnostic report from Feb 9 (opencode_2026-02-09-134500) shows clear unbounded growth:
Footprint: 243.58 MB -> 1493.97 MB (+1250.39 MB) in ~35 minutes
Writes: 2147.49 MB of file backed memory dirtied over 7760 seconds
(276.75 KB/s average, exceeding limit of 24.86 KB/s)
A single opencode instance grew from 244 MB to 1.5 GB in 35 minutes while also writing 2.1 GB to disk. This is characteristic of a memory leak — possibly conversation context accumulation, uncollected tool output buffers, or session state that is never freed.
Reboot Frequency
From last reboot, the majority of reboots are not preceded by a graceful shutdown, indicating forced restarts:
Date
Type
Feb 11 01:47
Kernel panic (confirmed)
Feb 9 00:28
Kernel panic (confirmed)
Feb 5 20:51
Graceful shutdown/reboot
Feb 3 17:50
Graceful shutdown/reboot
Feb 3 10:15
Forced reboot (no shutdown)
Feb 3 01:31
Forced reboot (no shutdown)
Feb 2 00:47
Forced reboot (no shutdown)
Jan 31 22:41
Forced reboot (no shutdown)
Jan 29 17:26
Forced reboot (no shutdown)
Jan 28 14:36
Forced reboot (no shutdown)
Jan 28 01:19
Forced reboot (no shutdown)
~10 forced reboots in 2 weeks. The overnight pattern (01:47, 00:28, 01:31, 00:47, 01:19) suggests opencode sessions left running overnight accumulate memory until the system panics.
Additional Jetsam Events (Feb 7)
Two Jetsam OOM events on Feb 7 show 5+ opencode instances ranging from 533 MB to 1,057 MB each, alongside a Python process at 6.4 GB. opencode was consistently in the top consumers.
Summary
Root cause: opencode has a memory leak — individual instances grow unboundedly (~1.25 GB in 35 min observed)
Compounding factor: Stale/idle opencode sessions are not cleaned up, leading to 10+ zombie-like instances accumulating
System impact: On 16 GB machines, 3–4 forgotten sessions are sufficient to exhaust RAM + swap and trigger a kernel panic
Disk swell: Consistent with the 18–23 swapfiles observed; macOS creates ~2 GB swap files, so 23 × 2 GB = ~46 GB matches the ~50 GB disk swell reported in this issue
Frequency: ~10 forced reboots in 2 weeks, primarily overnight when sessions are idle but still consuming memory
Based on the forensic analysis in the issue and the PRs I reviewed:
What the PRs would fix
The output += chunk string concatenation issue (PRs #12053, #9693, #10392) is almost certainly a real and significant leak. This O(n²) pattern in bash/PTY output is a well-known pathological case in JavaScript/Bun — it creates massive GC pressure and intermediate string allocations that can easily explain the 244 MB to 1.5 GB growth in 35 minutes that @dzianisv observed. Multiple PRs converge on this fix independently, which gives confidence it's a real problem.
The subscription/listener leaks (PRs #7914, #9146, #9147) are also legitimate — unremoved listeners accumulate closures that retain references to large objects.
What the PRs likely don't fully solve
2. @Thejuampi already tested PR #10392 (the most comprehensive one) and reported it didn't help for their Java/jdtls project. This suggests the LSP layer itself may be a major contributor that isn't adequately addressed — jdtls is notoriously memory-hungry, and simply capping diagnostics at 5K files may not be enough if the LSP client holds references to response objects, document buffers, or AST data.
3. Overnight idle accumulation — The kernel panics happen at 01:47, 00:28, 01:31 etc. The PRs add eviction on access (LRU) and creation (FIFO), but they don't add periodic idle cleanup that would reclaim memory when opencode is sitting unused overnight. PR #12259 has a TTL-based cleanup interval for ACP sessions, but not for the other caches.
4. No actual memory profiling — None of the PRs include heap snapshots or profiling data showing before/after. They're based on code review and reasonable assumptions, not measurement. The real dominant leak source could be something none of them address.
Bottom line
Merging the bash/PTY output fix (especially #9693's disk-streaming approach) and the subscription cleanup would meaningfully reduce memory growth. But I'd estimate they address maybe 40-60% of the problem. The multi-instance accumulation, LSP memory, and idle session cleanup gaps remain unaddressed. A proper fix likely requires heap profiling to identify what's actually holding memory in a real long-running session.
After a deep dive into the codebase, here are the actual root causes ranked by impact, and a concrete fix plan.
1. No graceful shutdown on signals (THE #1 cause of zombie processes)
This is the biggest problem and explains the 10 zombie processes in the forensic analysis. index.ts:153 has a finally block that calls process.exit() but never calls Instance.disposeAll(). There are zeroSIGTERM/SIGINT/SIGHUP handlers anywhere in the codebase. When you close a terminal:
The shell sends SIGHUP to opencode
Bun's default handler exits immediately
No cleanup runs — no LSP shutdown, no MCP close, no bash kill
Bash tool processes are spawned with detached: true (bash.ts:168), so they survive the parent dying
LSP servers (up to 25 per instance) may also linger since cleanup never fires
This single gap explains both the zombie processes AND the multiplying memory. Each time you open a terminal, run opencode, and close the terminal without a clean exit, you leave behind orphaned LSP/MCP/bash processes.
Fix: Add SIGTERM/SIGINT/SIGHUP handlers that call Instance.disposeAll() before exiting. Call disposeAll() in the finally block.
2. Unbounded output += chunk in bash tool (bash.ts:171-182)
This is the #1 per-process memory growth cause. Every bash command accumulates its entire output as a string via output += chunk.toString(). For verbose builds or large find outputs, this creates O(n²) memory pressure from string concatenation. The output is only truncated after the command finishes — by then, memory has already spiked.
Fix: Stream to disk above a threshold (like PR #9693 proposes), or use a Buffer[] ring buffer with a cap.
3. Deep clone of entire message history every prompt loop iteration (prompt.ts:577)
clone(msgs) deep-copies every message including all tool outputs on every loop turn. For a long conversation with large tool results, this temporarily doubles memory. Combined with MessageV2.stream() reloading all messages from disk each iteration (line 276), each turn has massive transient allocations.
Fix: Replace the deep clone with shallow copies of only the parts that get mutated (the <system-reminder> wrapping), or mutate in place and undo after.
4. ACP sessions never cleaned up (acp/session.ts:9)
The sessions Map has no delete(), no clear(), no dispose callback. Sessions accumulate forever.
Fix: Add a dispose callback to the Instance.state() call that clears the map.
5. PermissionNext and Question missing disposal callbacks
Unlike the legacy Permission module (which properly rejects pending promises on dispose), both PermissionNext (permission/next.ts:108) and Question (question/index.ts:82) register Instance.state()without a dispose callback. Pending promises and their closures leak on instance disposal.
Fix: Add dispose callbacks that reject all pending promises, matching the pattern already used in the legacy Permission module.
The read object tracks [sessionID][path] -> Date and entries are never evicted.
Fix: Add cleanup when sessions end, or add a dispose callback.
Proposed Implementation (~8 files)
File
Change
index.ts
Add SIGTERM/SIGINT/SIGHUP handlers that call disposeAll() before exit
tool/bash.ts
Cap output accumulation with a ring buffer or disk streaming
session/prompt.ts
Replace clone(msgs) with targeted shallow copies
acp/session.ts
Add dispose callback to clear sessions map
permission/next.ts
Add dispose callback to reject pending promises
question/index.ts
Add dispose callback to reject pending promises
file/time.ts
Add dispose callback to clear read tracking
util/queue.ts
Add close() method to drain pending resolvers
The signal handling fix alone would eliminate the zombie process problem (the 10-process / 9.4 GB scenario from the forensic analysis). Combined with the bash output cap and prompt clone fix, this covers the per-process memory growth. The remaining fixes are defense-in-depth for long-running sessions.
This should address ~90% of the memory exhaustion and kernel panic symptoms reported in this issue.
Fix 1: Graceful shutdown via signal handlers (Critical — eliminates zombie processes)
Problem: No SIGTERM/SIGINT/SIGHUP handlers exist anywhere. When a terminal is closed, SIGHUP kills the process instantly. Instance.disposeAll() never runs. Child processes spawned with detached: true (bash tool) survive indefinitely. LSP/MCP servers are orphaned.
The only reliable cleanup path today is the TUI's graceful exit flow (ExitProvider -> shutdown RPC -> worker Instance.disposeAll()). Every other exit path (signals, serve command, errors) skips cleanup entirely.
Changes:
index.ts: Add SIGTERM/SIGINT/SIGHUP handlers that call Instance.disposeAll() before exiting. Also call disposeAll() in the existing finally block before process.exit().
worker.ts: Add matching signal handlers in the worker thread, since the worker owns the Instance state and runs disposeAll().
serve.ts: Replace the unreachable cleanup code (await new Promise(() => {}) blocks forever, making lines 48-56 dead code) with a signal-based exit that properly cleans up.
Problem:bash.ts:171 uses let output = "" with output += chunk.toString() on every data event. The full output string grows unboundedly during execution. The same pattern exists in prompt.tsshell() method (~line 1499). While not a persistent leak (freed when the function returns), for verbose commands this creates massive transient memory spikes with O(n²) GC pressure from string concatenation.
Changes:
bash.ts: Accumulate output in a Buffer[] array. Cap total size (e.g., 10MB). Drop oldest chunks when exceeded. Join only at the end for the return value.
prompt.tsshell(): Apply the same ring buffer pattern.
Fix 3: Deep clone of message history in prompt loop (High — doubles memory per turn)
Problem:prompt.ts:577 calls clone(msgs) (remeda deep clone) on the entire session message history every loop iteration. This exists because lines 580-596 mutate part.text to wrap it in <system-reminder> tags. For long sessions with large tool outputs, this temporarily doubles memory.
Changes:
prompt.ts: Instead of deep-cloning everything, create the reminder-wrapped text without mutating the original messages. Build a map of partID -> wrappedText and apply it during model message construction, or shallow-copy only the specific parts that need mutation.
Problem:PermissionNext (permission/next.ts:108) and Question (question/index.ts:82) register Instance.state()without dispose callbacks. The legacy Permission (permission/index.ts:53-83) does it correctly — it provides a dispose callback that rejects all pending promises. When an instance is disposed, pending promise closures (holding references to session data, tool contexts, etc.) are never freed.
Changes:
permission/next.ts: Add dispose callback matching the pattern in permission/index.ts.
question/index.ts: Add dispose callback that rejects all pending question promises.
Fix 5: ACP session cleanup (Medium)
Problem:ACPSessionManager (acp/session.ts:9) stores sessions in a Map with no delete() or dispose() method. Sessions accumulate forever.
Changes:
acp/session.ts: Add a dispose() method that clears the sessions map. Wire it into the instance lifecycle.
Fix 6: AsyncQueue close mechanism (Medium)
Problem:util/queue.ts has no close()/termination mechanism. The async iterator is infinite (while(true)). If consumers stop iterating, pending resolver callbacks stay in memory as unresolved promises.
Changes:
util/queue.ts: Add a close() method that sets a flag, resolves/rejects all pending resolvers, and causes the async iterator to terminate.
Fix 7: FileTime cleanup (Low-Medium)
Problem:file/time.ts tracks [sessionID][path] -> Date for every file read, with no eviction or cleanup.
Changes:
file/time.ts: Add a dispose callback. Add a clear(sessionID) method for session-level cleanup.
Slow leak from pending promises on abandoned sessions
~5%
ACP/Queue/FileTime
Defense-in-depth for long-running sessions
~5%
Total estimated coverage: ~90% of reported symptoms.
The signal handler fix alone eliminates the multi-process accumulation problem. Combined with the bash output cap, it addresses the two primary mechanisms that cause kernel panics on 16 GB machines.
After a thorough self-review of the initial commit, I found and corrected several issues:
Problems caught and fixed
serve.ts imported modules that don't exist on dev — Whisper, Coqui, Tunnel were accidentally included from a local feature branch. This would have broken the upstream build. Removed all voice/tunnel code, kept only the signal-based exit + Instance.disposeAll() fix.
bash.ts metadata preview bug — chunks.slice(0, 1).toString() calls Array.toString() (joins with commas), not Buffer.concat(). This would produce garbled TUI output. Replaced with a separate preview string built incrementally and capped at 30KB.
prompt.ts shell metadata called Buffer.concat() on every data chunk — This is O(n²), the same problem as the original output +=. Fixed with the same capped preview approach.
Removed ACPSessionManager.dispose() — Nobody calls it. The class is instantiated in ACPAgentBridge with no lifecycle hook to the Instance state system. Cosmetic change that fixes nothing.
Removed AsyncQueue.close() — Nobody calls it. The two AsyncQueue instances in tui.ts are module-level singletons with no dispose lifecycle. Cosmetic change that fixes nothing.
snapshot: false is a mitigation and can help significantly for some workloads, but it is not a full root-cause guarantee by itself. The larger stability improvement comes from combining it with the runtime fixes above (MCP fanout prevention + shell stream caps/throttling + reduced clone churn + bounded file-time state).
Current status
After these changes, stress probes showed major RSS reduction on high-output shell scenarios versus pre-fix runs. Continuing to monitor long multi-session usage for remaining growth vectors (especially long-lived session/message retention patterns and LSP-heavy workloads).
This was not caused by SQLite mmap growth or a 512 MB Bun heap cap.
What the code actually does:
serve starts the memory monitor via Memory.start("serve"), but that is diagnostics only, not a heap limiter.
The relevant flags are OPENCODE_MEMORY_MONITOR*.
The docs example uses OPENCODE_MEMORY_MONITOR_THRESHOLD_MB=5120, which is a snapshot/monitor threshold, not a hard cap.
The frontend debug bar also shows performance.memory.jsHeapSizeLimit, but that is Chromium-only client-side memory, not the Bun server.
What caused the spike instead:
The sidebar/session warm-prefetch path was requesting full GET /session/:id/message?limit=200 payloads.
Some persisted sessions contained very large tool and reasoning parts.
The Bun server had to hydrate those rows into JS objects/strings and JSON-encode them, which drove WebKit Malloc / Bun JSC heap into the multi-GB range.
Evidence:
Live DB had about 458 MB of tool parts and about 92.8 MB of reasoning parts, with two sessions around 183 MB each.
SQLite mmap_size was 0, cache size was small, and DB file size was about 624 MB.
vmmap growth was concentrated in WebKit Malloc, not mapped SQLite regions.
Fix validated locally:
Added preview=true mode for session message fetches so sidebar prefetch only loads lightweight preview payloads.
Synthetic route check with a 10 MB persisted tool output:
SessionSummary.summarize() also calls Session.messages({ sessionID })
Session.messages() fully hydrates the session with part payloads
prune was also being fired without awaiting, so repeated turns can stack background work
That combination is a plausible path to:
memory blow-up on large sessions
transient ENOSPC / swap pressure on macOS
SQLITE_FULL while writing part updates
wedged serve process that stops answering even health checks
I patched this locally in a working tree to reduce hot-path pressure:
changed compaction prune to page backward through message history instead of loading the full session
added per-session scheduling/coalescing for prune
added per-session scheduling/coalescing for session summary work
added regression coverage for prune behavior
Focused validation on the local patch:
bun test test/session/compaction.test.ts test/session/session.test.ts passed
bun typecheck passed
This is not the same as actual disk-full conditions in #17589; here the machine still had free disk when SQLite started throwing database or disk is full.
Investigation update (2026-03-31 UTC) from a live run on macOS:
opencode serve briefly reached >2 GiB RSS, then rotated to a new PID.
Current diagnostics from /global/memory?children=true show process-tree RSS in the ~1.5-2.6 GiB range with 16-23 descendants while root process RSS is typically ~475-605 MiB.
Dominant memory is in MCP descendants, not PTYs: duplicated chrome-devtools-mcp stacks (--autoConnect and --browserUrl), plus playwriter, whisper-mcp, @modelcontextprotocol/server-memory, and telemetry watchdog child processes.
Descendant tree sample: descendants=23 rss_mb=1744.6; multiple MCP processes are each in the ~120-180 MiB range.
dev.log shows high MCP churn: many repeated failed to get prompts errors (playwriter/whisper) and repeated shared-client reuse/close activity.
watchdog-stderr.log is repeatedly logging /Users/engineer/workspace/opencode/script/memory-watchdog.sh: No such file or directory (thousands of lines), so watchdog instrumentation appears misconfigured/broken in this setup.
Session counts are high (total=333, active=141), but pty.active=0 and instance cache is bounded (size<=4), so this does not look like a PTY leak.
Hypothesis: the recurring memory pressure is primarily MCP process fan-out/churn (including duplicate CDP MCP entries) plus prompt-probe retry noise, not a single PTY leak path.
Primary culprit was opencode process fan-out, with Chrome as the biggest single process.
Crash time: March 31, 2026 at 03:08:13 -0700.
Reboot trigger from panic log: watchdog timeout: no checkins from watchdogd in 94 seconds with 9 swapfiles and LOW swap space in /Library/Logs/DiagnosticReports/panic-full-2026-03-31-
030813.0002.panic.
At panic snapshot:
opencode: 995 processes, 23.8 GB RSS total (~53.3% of all process RSS captured).
Google Chrome* family: 9.1 GB RSS total (~20.4%).
Largest single process: Google Chrome Helper (Renderer) PID 56594 at 4.96 GB RSS.
Largest opencode process: PID 49761 at 0.88 GB RSS, plus many opencode processes clustered around ~240–260 MB.
So if you want one process answer: opencode (runaway process count) caused most of the memory pressure; Chrome renderer was the top individual memory hog that worsened it.
which() resolves to process.execPath, so the command is effectively:
opencode info @opencode-ai/plugin version
Evidence from panic + runtime
Panic processByPid had 995 entries with procname="opencode".
All 995 had thread name HTTP Client (signature of those helper subprocesses).
At runtime under opencode serve, child processes with PPID of the server include multiple concurrent:
/Users/engineer/.local/bin/opencode info @opencode-ai/plugin version
Logs during churn also show heavy repeated polling and instance churn (creating instance + No context found for instance).
So the fan-out is coming from repeated plugin version checks (opencode info ...) triggered through the /agent -> Agent.list -> Config.get path under high polling/workspace churn.
I searched open/closed issues and PRs for this exact signature (opencode info @opencode-ai/plugin version subprocess fan-out from /agent polling path).
Result:
I did not find a prior issue/PR that already documents this exact fan-out root cause.
The closest matches are broader memory/process tickets, not the same trigger path:
Activity
github-actions commentedon Feb 8, 2026
This issue might be a duplicate of existing issues. Please check:
Feel free to ignore if none of these address your specific case.
dzianisv commentedon Feb 11, 2026
Forensic Analysis: Kernel Panic Root Cause (Feb 11, 2026)
Machine: MacBook Pro M1 (MacBookPro17,1), 16 GB RAM, macOS 15.5 (24F74), ~30 GB free disk
Kernel Panic Details
Two kernel panics in 3 days, both identical cause:
Feb 11, 01:47 AM:
Feb 9, 00:28 AM:
Both times the system was so memory-starved that the kernel watchdog daemon couldn't check in within 91 seconds, triggering a hard panic and forced reboot. Boot faults recorded as
wdog,reset_in_1 timeout.Jetsam (OOM) Events Confirm
opencodeas Primary ConsumerThe macOS Jetsam event from Feb 10 explicitly identifies:
Memory snapshot at time of Jetsam kill:
10 opencode processes consuming ~9.4 GB on a 16 GB machine. That's 59% of total physical RAM from opencode alone.
System memory state at time of Jetsam:
Evidence of Memory Leak
The opencode diagnostic report from Feb 9 (
opencode_2026-02-09-134500) shows clear unbounded growth:A single opencode instance grew from 244 MB to 1.5 GB in 35 minutes while also writing 2.1 GB to disk. This is characteristic of a memory leak — possibly conversation context accumulation, uncollected tool output buffers, or session state that is never freed.
Reboot Frequency
From
last reboot, the majority of reboots are not preceded by a graceful shutdown, indicating forced restarts:~10 forced reboots in 2 weeks. The overnight pattern (01:47, 00:28, 01:31, 00:47, 01:19) suggests opencode sessions left running overnight accumulate memory until the system panics.
Additional Jetsam Events (Feb 7)
Two Jetsam OOM events on Feb 7 show 5+ opencode instances ranging from 533 MB to 1,057 MB each, alongside a Python process at 6.4 GB. opencode was consistently in the top consumers.
Summary
dzianisv commentedon Feb 11, 2026
Analysis of Related Open PRs
There are multiple open PRs addressing the same underlying memory leak problems described in this issue, though none directly reference #12687.
Most Relevant Open PRs
output += chunkstring concatenationKey Observations
Root Causes Identified Across PRs
The community has converged on these root causes:
output += chunkstring concatenation in bash/PTY (O(n²) memory)A coordinated effort to review and merge the best parts of these PRs would likely address the kernel panic symptoms reported here.
dzianisv commentedon Feb 11, 2026
Based on the forensic analysis in the issue and the PRs I reviewed:
What the PRs would fix
The output += chunk string concatenation issue (PRs #12053, #9693, #10392) is almost certainly a real and significant leak. This O(n²) pattern in bash/PTY output is a well-known pathological case in JavaScript/Bun — it creates massive GC pressure and intermediate string allocations that can easily explain the 244 MB to 1.5 GB growth in 35 minutes that @dzianisv observed. Multiple PRs converge on this fix independently, which gives confidence it's a real problem.
The subscription/listener leaks (PRs #7914, #9146, #9147) are also legitimate — unremoved listeners accumulate closures that retain references to large objects.
What the PRs likely don't fully solve
2. @Thejuampi already tested PR #10392 (the most comprehensive one) and reported it didn't help for their Java/jdtls project. This suggests the LSP layer itself may be a major contributor that isn't adequately addressed — jdtls is notoriously memory-hungry, and simply capping diagnostics at 5K files may not be enough if the LSP client holds references to response objects, document buffers, or AST data.
3. Overnight idle accumulation — The kernel panics happen at 01:47, 00:28, 01:31 etc. The PRs add eviction on access (LRU) and creation (FIFO), but they don't add periodic idle cleanup that would reclaim memory when opencode is sitting unused overnight. PR #12259 has a TTL-based cleanup interval for ACP sessions, but not for the other caches.
4. No actual memory profiling — None of the PRs include heap snapshots or profiling data showing before/after. They're based on code review and reasonable assumptions, not measurement. The real dominant leak source could be something none of them address.
Bottom line
Merging the bash/PTY output fix (especially #9693's disk-streaming approach) and the subscription cleanup would meaningfully reduce memory growth. But I'd estimate they address maybe 40-60% of the problem. The multi-instance accumulation, LSP memory, and idle session cleanup gaps remain unaddressed. A proper fix likely requires heap profiling to identify what's actually holding memory in a real long-running session.
dzianisv commentedon Feb 11, 2026
Root Cause Analysis and Proposed Fix
After a deep dive into the codebase, here are the actual root causes ranked by impact, and a concrete fix plan.
1. No graceful shutdown on signals (THE #1 cause of zombie processes)
This is the biggest problem and explains the 10 zombie processes in the forensic analysis.
index.ts:153has afinallyblock that callsprocess.exit()but never callsInstance.disposeAll(). There are zeroSIGTERM/SIGINT/SIGHUPhandlers anywhere in the codebase. When you close a terminal:SIGHUPto opencodedetached: true(bash.ts:168), so they survive the parent dyingThis single gap explains both the zombie processes AND the multiplying memory. Each time you open a terminal, run opencode, and close the terminal without a clean exit, you leave behind orphaned LSP/MCP/bash processes.
Fix: Add
SIGTERM/SIGINT/SIGHUPhandlers that callInstance.disposeAll()before exiting. CalldisposeAll()in thefinallyblock.2. Unbounded
output += chunkin bash tool (bash.ts:171-182)This is the #1 per-process memory growth cause. Every bash command accumulates its entire output as a string via
output += chunk.toString(). For verbose builds or largefindoutputs, this creates O(n²) memory pressure from string concatenation. The output is only truncated after the command finishes — by then, memory has already spiked.Fix: Stream to disk above a threshold (like PR #9693 proposes), or use a
Buffer[]ring buffer with a cap.3. Deep clone of entire message history every prompt loop iteration (
prompt.ts:577)clone(msgs)deep-copies every message including all tool outputs on every loop turn. For a long conversation with large tool results, this temporarily doubles memory. Combined withMessageV2.stream()reloading all messages from disk each iteration (line 276), each turn has massive transient allocations.Fix: Replace the deep clone with shallow copies of only the parts that get mutated (the
<system-reminder>wrapping), or mutate in place and undo after.4. ACP sessions never cleaned up (
acp/session.ts:9)The sessions Map has no
delete(), noclear(), no dispose callback. Sessions accumulate forever.Fix: Add a dispose callback to the
Instance.state()call that clears the map.5. PermissionNext and Question missing disposal callbacks
Unlike the legacy
Permissionmodule (which properly rejects pending promises on dispose), bothPermissionNext(permission/next.ts:108) andQuestion(question/index.ts:82) registerInstance.state()without a dispose callback. Pending promises and their closures leak on instance disposal.Fix: Add dispose callbacks that reject all pending promises, matching the pattern already used in the legacy
Permissionmodule.6. FileTime tracking grows unboundedly (
file/time.ts:11-22)The
readobject tracks[sessionID][path] -> Dateand entries are never evicted.Fix: Add cleanup when sessions end, or add a dispose callback.
Proposed Implementation (~8 files)
index.tsSIGTERM/SIGINT/SIGHUPhandlers that calldisposeAll()before exittool/bash.tssession/prompt.tsclone(msgs)with targeted shallow copiesacp/session.tspermission/next.tsquestion/index.tsfile/time.tsutil/queue.tsclose()method to drain pending resolversThe signal handling fix alone would eliminate the zombie process problem (the 10-process / 9.4 GB scenario from the forensic analysis). Combined with the bash output cap and prompt clone fix, this covers the per-process memory growth. The remaining fixes are defense-in-depth for long-running sessions.
This should address ~90% of the memory exhaustion and kernel panic symptoms reported in this issue.
dzianisv commentedon Feb 11, 2026
Implementation Plan
Fix 1: Graceful shutdown via signal handlers (Critical — eliminates zombie processes)
Problem: No
SIGTERM/SIGINT/SIGHUPhandlers exist anywhere. When a terminal is closed,SIGHUPkills the process instantly.Instance.disposeAll()never runs. Child processes spawned withdetached: true(bash tool) survive indefinitely. LSP/MCP servers are orphaned.The only reliable cleanup path today is the TUI's graceful exit flow (
ExitProvider->shutdownRPC -> workerInstance.disposeAll()). Every other exit path (signals,servecommand, errors) skips cleanup entirely.Changes:
index.ts: AddSIGTERM/SIGINT/SIGHUPhandlers that callInstance.disposeAll()before exiting. Also calldisposeAll()in the existingfinallyblock beforeprocess.exit().worker.ts: Add matching signal handlers in the worker thread, since the worker owns the Instance state and runsdisposeAll().serve.ts: Replace the unreachable cleanup code (await new Promise(() => {})blocks forever, making lines 48-56 dead code) with a signal-based exit that properly cleans up.Fix 2: Bash/shell output accumulation (High — largest per-process memory spike)
Problem:
bash.ts:171useslet output = ""withoutput += chunk.toString()on every data event. The full output string grows unboundedly during execution. The same pattern exists inprompt.tsshell()method (~line 1499). While not a persistent leak (freed when the function returns), for verbose commands this creates massive transient memory spikes with O(n²) GC pressure from string concatenation.Changes:
bash.ts: Accumulate output in aBuffer[]array. Cap total size (e.g., 10MB). Drop oldest chunks when exceeded. Join only at the end for the return value.prompt.tsshell(): Apply the same ring buffer pattern.Fix 3: Deep clone of message history in prompt loop (High — doubles memory per turn)
Problem:
prompt.ts:577callsclone(msgs)(remeda deep clone) on the entire session message history every loop iteration. This exists because lines 580-596 mutatepart.textto wrap it in<system-reminder>tags. For long sessions with large tool outputs, this temporarily doubles memory.Changes:
prompt.ts: Instead of deep-cloning everything, create the reminder-wrapped text without mutating the original messages. Build a map ofpartID -> wrappedTextand apply it during model message construction, or shallow-copy only the specific parts that need mutation.Fix 4: Missing dispose callbacks (High — pending promises leak on shutdown)
Problem:
PermissionNext(permission/next.ts:108) andQuestion(question/index.ts:82) registerInstance.state()without dispose callbacks. The legacyPermission(permission/index.ts:53-83) does it correctly — it provides a dispose callback that rejects all pending promises. When an instance is disposed, pending promise closures (holding references to session data, tool contexts, etc.) are never freed.Changes:
permission/next.ts: Add dispose callback matching the pattern inpermission/index.ts.question/index.ts: Add dispose callback that rejects all pending question promises.Fix 5: ACP session cleanup (Medium)
Problem:
ACPSessionManager(acp/session.ts:9) stores sessions in aMapwith nodelete()ordispose()method. Sessions accumulate forever.Changes:
acp/session.ts: Add adispose()method that clears the sessions map. Wire it into the instance lifecycle.Fix 6: AsyncQueue close mechanism (Medium)
Problem:
util/queue.tshas noclose()/termination mechanism. The async iterator is infinite (while(true)). If consumers stop iterating, pending resolver callbacks stay in memory as unresolved promises.Changes:
util/queue.ts: Add aclose()method that sets a flag, resolves/rejects all pending resolvers, and causes the async iterator to terminate.Fix 7: FileTime cleanup (Low-Medium)
Problem:
file/time.tstracks[sessionID][path] -> Datefor every file read, with no eviction or cleanup.Changes:
file/time.ts: Add a dispose callback. Add aclear(sessionID)method for session-level cleanup.Expected impact
Total estimated coverage: ~90% of reported symptoms.
The signal handler fix alone eliminates the multi-process accumulation problem. Combined with the bash output cap, it addresses the two primary mechanisms that cause kernel panics on 16 GB machines.
fix: resolve memory leaks and zombie processes from missing cleanup h…
fix: resolve memory leaks and zombie processes from missing cleanup h…
dzianisv commentedon Feb 11, 2026
PR #13183 — Review and Cleanup
After a thorough self-review of the initial commit, I found and corrected several issues:
Problems caught and fixed
serve.tsimported modules that don't exist ondev—Whisper,Coqui,Tunnelwere accidentally included from a local feature branch. This would have broken the upstream build. Removed all voice/tunnel code, kept only the signal-based exit +Instance.disposeAll()fix.bash.tsmetadata preview bug —chunks.slice(0, 1).toString()callsArray.toString()(joins with commas), notBuffer.concat(). This would produce garbled TUI output. Replaced with a separatepreviewstring built incrementally and capped at 30KB.prompt.tsshell metadata calledBuffer.concat()on every data chunk — This is O(n²), the same problem as the originaloutput +=. Fixed with the same capped preview approach.Removed
ACPSessionManager.dispose()— Nobody calls it. The class is instantiated inACPAgentBridgewith no lifecycle hook to the Instance state system. Cosmetic change that fixes nothing.Removed
AsyncQueue.close()— Nobody calls it. The twoAsyncQueueinstances intui.tsare module-level singletons with no dispose lifecycle. Cosmetic change that fixes nothing.Final PR: 8 files, all meaningful
Instance.disposeAll()index.ts,worker.ts,serve.tsbash.ts,prompt.tsprompt.tsclone(msgs)every prompt looppermission/next.ts,question/index.tsfile/time.tsTesting
tsgo --noEmitpasses across all 17 packagesPR: #13183
37 remaining items
OpenCodeEngineer commentedon Mar 15, 2026
Another leak-path hardening commit pushed:
Change:
FileTimenow uses bounded maps instead of unbounded nested objects.OPENCODE_FILETIME_SESSION_MAX(default256)OPENCODE_FILETIME_FILE_MAX(default1024)Why:
Validation:
test/file/time.test.tsfile/time,session,acp,mcp)OpenCodeEngineer commentedon Mar 15, 2026
Another focused fix landed for high-output shell workloads:
Commits:
What changed:
SessionPrompt.shellnow:OPENCODE_SHELL_CAPTURE_MAX_BYTES, default 2MB)message.part.updatedmetadata emissionsTruncate.output(...)BashToolnow:OPENCODE_BASH_CAPTURE_MAX_BYTES, default 2MB)clippedmetadata flagValidation:
POST /session/:id/shellwith ~8MB output command, 12 times):2) in both casesThis removes another major memory pressure source during shell-heavy sessions.
OpenCodeEngineer commentedon Mar 15, 2026
Quick follow-up on the
snapshot: falseworkaround people reported:snapshotis a valid top-level config key inopencode.json.false, OpenCode skips workspace Git snapshot tracking used for step diff/revert flows.I also pushed a docs/schema update so this option is discoverable:
packages/web/src/content/docs/config.mdx(### Snapshots)packages/opencode/src/config/config.tsThis should address the "option exists but not documented" confusion while leak root-cause work continues.
OpenCodeEngineer commentedon Mar 15, 2026
Consolidated update: applied fixes + practical memory mitigations
Fixes already implemented (with commits)
FileTimeread state (session/file map caps)/session/.../shell+ bash tool)snapshot: falseworkaroundWorkarounds / options to reduce memory usage now
{ "snapshot": false }Keep MCP servers disabled unless needed (
mcp.<name>.enabled: false), then enable selectively.Prefer lower-output shell usage patterns (very large continuous stdout/stderr still creates pressure even with caps/throttling).
Keep compaction enabled (
compaction.auto: true,compaction.prune: true).Important note
snapshot: falseis a mitigation and can help significantly for some workloads, but it is not a full root-cause guarantee by itself. The larger stability improvement comes from combining it with the runtime fixes above (MCP fanout prevention + shell stream caps/throttling + reduced clone churn + bounded file-time state).Current status
After these changes, stress probes showed major RSS reduction on high-output shell scenarios versus pre-fix runs. Continuing to monitor long multi-session usage for remaining growth vectors (especially long-lived session/message retention patterns and LSP-heavy workloads).
OpenCodeEngineer commentedon Mar 15, 2026
Update: I rebased/squashed memory-fix history on
dzianisv/opencode:devinto commit-per-fix units. Canonical commits are now:c673d81df— serve memory workload profiler (+ threshold stop/docs consolidated)f6ebb6f8c— share + hard-close MCP clients across instancesed4756748— dedupe instance bootstraps + cache headroom tuning3cd75c562— MCP lazy/opt-in runtime path + command refresh/tests + docs consolidation3035ace23— remove deep clone churn on part update bus events53afa4e3c— cap file read timestamps per session (bounded growth)b0e7dbcc8— cap + throttle shell output streaming (docs consolidated)8dfa12ac5— documentsnapshot: falseworkaround in schema/docsNo functional delta from the prior tip commit; this was history consolidation for cleaner review and traceability.
OpenCodeEngineer commentedon Mar 15, 2026
Additional fix shipped for shell-tool memory safety:
fix(bash): spool output to disk instead of in-memory cappingWhat changed:
This keeps memory bounded while preserving full command output for post-processing (Read/Grep) without clipping the underlying output stream.
OpenCodeEngineer commentedon Mar 15, 2026
Traceability update: added a commit index for memory-related fixes in fork README.
README.md(Fork Memory Optimizations (dzianisv/dev)section)This section links the critical memory optimization commits/profiling additions in one place for easier review and bisecting.
dzianisv commentedon Mar 16, 2026
Findings from the memory investigation:
This was not caused by SQLite mmap growth or a 512 MB Bun heap cap.
What the code actually does:
servestarts the memory monitor viaMemory.start("serve"), but that is diagnostics only, not a heap limiter.OPENCODE_MEMORY_MONITOR*.OPENCODE_MEMORY_MONITOR_THRESHOLD_MB=5120, which is a snapshot/monitor threshold, not a hard cap.performance.memory.jsHeapSizeLimit, but that is Chromium-only client-side memory, not the Bun server.What caused the spike instead:
GET /session/:id/message?limit=200payloads.toolandreasoningparts.WebKit Malloc/ Bun JSC heap into the multi-GB range.Evidence:
toolparts and about 92.8 MB ofreasoningparts, with two sessions around 183 MB each.mmap_sizewas0, cache size was small, and DB file size was about 624 MB.vmmapgrowth was concentrated inWebKit Malloc, not mapped SQLite regions.Fix validated locally:
preview=truemode for session message fetches so sidebar prefetch only loads lightweight preview payloads.dzianisv commentedon Mar 16, 2026
I hit a concrete live serve failure that appears to be part of this broader memory/perf issue.
Observed on a long-running
opencode serveinstance on macOS:/global/healthdev.logrecordeddatabase or disk is fullfromSession.updatePart()dfstill showed ~17 GiB free)script/memory-watchdog.shalso started failing withprintf: write error: No space left on deviceThe strongest code-path correlation I found is large-session history hydration on the prompt hot path:
SessionCompaction.prune()callsSession.messages({ sessionID })SessionSummary.summarize()also callsSession.messages({ sessionID })Session.messages()fully hydrates the session with part payloadsThat combination is a plausible path to:
SQLITE_FULLwhile writing part updatesI patched this locally in a working tree to reduce hot-path pressure:
Focused validation on the local patch:
bun test test/session/compaction.test.ts test/session/session.test.tspassedbun typecheckpassedThis is not the same as actual disk-full conditions in #17589; here the machine still had free disk when SQLite started throwing
database or disk is full.OpenCodeEngineer commentedon Mar 31, 2026
Investigation update (2026-03-31 UTC) from a live run on macOS:
opencode servebriefly reached >2 GiB RSS, then rotated to a new PID./global/memory?children=trueshow process-tree RSS in the ~1.5-2.6 GiB range with 16-23 descendants while root process RSS is typically ~475-605 MiB.chrome-devtools-mcpstacks (--autoConnectand--browserUrl), plusplaywriter,whisper-mcp,@modelcontextprotocol/server-memory, and telemetry watchdog child processes.descendants=23 rss_mb=1744.6; multiple MCP processes are each in the ~120-180 MiB range.dev.logshows high MCP churn: many repeatedfailed to get promptserrors (playwriter/whisper) and repeated shared-client reuse/close activity.watchdog-stderr.logis repeatedly logging/Users/engineer/workspace/opencode/script/memory-watchdog.sh: No such file or directory(thousands of lines), so watchdog instrumentation appears misconfigured/broken in this setup.total=333,active=141), butpty.active=0and instance cache is bounded (size<=4), so this does not look like a PTY leak.Hypothesis: the recurring memory pressure is primarily MCP process fan-out/churn (including duplicate CDP MCP entries) plus prompt-probe retry noise, not a single PTY leak path.
dzianisv commentedon Mar 31, 2026
rebooted overnight
OpenCodeEngineer commentedon Mar 31, 2026
Root-cause update from panic analysis (Mar 31, 2026 03:08:13 -0700): this was not 995 "agents"; it was a large subprocess storm.
Exact spawn path in code
GET /agentroute callsAgent.list()packages/opencode/src/server/server.tslines 434-453Agent.list()resolves state that callsConfig.get()packages/opencode/src/agent/agent.tslines 75, 80, 322-324Config.get()loops config dirs and schedules dependency checks per dirpackages/opencode/src/config/config.tslines 143-159needsInstall()(local channel) callsPackageRegistry.isOutdated("@opencode-ai/plugin", ...)packages/opencode/src/config/config.tslines 356-359PackageRegistry.info()executes:Process.run([which(), "info", pkg, field], ...)packages/opencode/src/bun/registry.tsline 50which()resolves toprocess.execPath, so the command is effectively:opencode info @opencode-ai/plugin versionEvidence from panic + runtime
processByPidhad995entries withprocname="opencode".HTTP Client(signature of those helper subprocesses).opencode serve, child processes with PPID of the server include multiple concurrent:/Users/engineer/.local/bin/opencode info @opencode-ai/plugin versioncreating instance+No context found for instance).So the fan-out is coming from repeated plugin version checks (
opencode info ...) triggered through the/agent->Agent.list->Config.getpath under high polling/workspace churn.OpenCodeEngineer commentedon Mar 31, 2026
Upstream duplicate scan update (2026-03-31):
I searched open/closed issues and PRs for this exact signature (
opencode info @opencode-ai/plugin versionsubprocess fan-out from/agentpolling path).Result:
serveandwebcommands leak child processes on exit; SSE heartbeat interval leaks on server-initiated close #14091 (child-process leak on exit)PackageRegistry.infooutput handling), but they do not address repeated concurrentinfosubprocess spawning under high polling/churn.Conclusion: this specific subprocess-storm path appears newly identified and should remain tracked here.
thdxr commentedon Apr 2, 2026
Closing in favor of #20695 where we're collecting all memory-related problems. Please follow that issue for updates.