Repository navigation
core: long-lived V2 server enters persistent allocation loop #36677
Description
Summary
A long-lived V2 opencode2 serve process entered a persistent high-frequency JavaScript allocation loop, consuming approximately one CPU core and 1.1–1.3 GB RSS while otherwise idle. A fresh server from the exact same binary and configuration remained below 1% CPU after recreating its clients and all 13 cached locations, indicating that the fault requires state accumulated over the process lifetime.
This is distinct from #36311: that issue concerns an unresponsive Bun server spinning in a native dead-socket write path after an SSE disconnect. In this incident the sampled CPU was overwhelmingly JavaScriptCore JIT execution and the server remained functional.
Environment
- opencode version: V2 compiled executable; exact commit/version unavailable from the captured diagnostic
- OS: Linux; exact distribution/kernel unavailable
- Terminal: Unavailable
- Shell: Unavailable
- Install/channel: V2 compiled binary
- Active plugins: Configured plugins were reproduced in the clean comparison server; exact list unavailable
- Affected process: PID 34125
- Control process from the same binary: PID 53040
Reproduction
The transition into the fault is intermittent and does not yet have a deterministic short reproduction.
- Run a V2
opencode2 serveprocess for an extended period with multiple TUI clients and locations. - Allow locations to undergo repeated idle expiry and reactivation cycles while sessions and filesystem activity occur normally.
- After sufficient accumulated runtime state, observe the server consuming approximately one CPU core continuously, including while sessions are idle.
- Start a second server from the exact same binary with isolated data and progressively recreate six SSE clients and all 13 locations.
- Observe that the fresh server remains below 1% CPU, while the long-lived process remains near one full core.
Expected Behavior
An idle V2 server should settle near zero CPU, and expiring locations, event listeners, plugin subscriptions, SSE requests, and session fibers should be finalized without accumulating runnable work.
Actual Behavior
The affected process remained near 94% lifetime CPU through most of an overnight idle period. RSS was generally 1.1–1.3 GB and peaked around 1.49 GB. A fresh server from the same binary used approximately 0.5% CPU and 370 MB RSS.
perf attributed approximately 80.4% of sampled cycles to JavaScriptCore JIT code. Only approximately 3.6% was in libc and kernel paths. The main thread was the only continuously runnable thread; other threads were sleeping or waiting.
A six-second all-thread strace sample recorded:
| Syscall | Calls |
|---|---|
madvise |
40,655 |
sched_yield |
29,291 |
futex |
18,767 |
pread64 |
11,318 |
clock_nanosleep |
2,141 |
epoll_pwait2 |
91 |
read |
73 |
A four-second main-thread-only sample recorded:
| Syscall | Calls |
|---|---|
madvise |
18,201 |
pread64 |
7,282 |
futex |
4,584 |
sched_yield |
1,192 |
Nearly every pread64 read /proc/34125/statm, at approximately 1,800–2,000 calls per second. The madvise calls were overwhelmingly MADV_DODUMP over newly allocated regions, often 16 KiB at a time. Controlled Bun comparisons and disassembly indicate these statm reads come from JSC allocation/heap checkpoints rather than an explicit OpenCode process.memoryUsage() loop.
Additional Context
Clean comparison
Ten-second perf stat measurements from another server using the exact same compiled binary, isolated data, and the same global/project configuration:
| Test state | CPU time in 10 seconds | Approx. CPU |
|---|---|---|
| Fresh idle server | 38.4 ms | 0.38% |
| Six idle SSE clients | 57.2 ms | 0.57% |
| Six clients plus one FFF/location graph | 52.0 ms | 0.52% |
| Six clients plus all 13 locations | 98.6 ms | 0.99% |
This rules out Bun idle behavior, SSE heartbeats, a normal FFF runtime, Parcel watchers, 13 cached location graphs, or their fresh combination as sufficient causes.
Ruled out by direct evidence
- FFF workers and watcher-owner threads accumulated effectively no CPU time.
- Parcel/notify threads were sleeping; there was no live kernel filesystem event storm.
- Detailed
pread64calls targeted/proc/34125/statm, not SQLite. - Network syscall activity was low.
- Six idle SSE subscriptions and 13 locations remained below 1% CPU in the clean comparison.
- Active model work did not account for the overnight CPU accumulation.
- The previously fixed zero-duration Effect file-logger batching loop is not present in this binary.
SSE amplification, but not root cause
The affected binary predates #36484 / commit 7913c4a490, which encodes each event once in a server-scoped feed instead of independently for every SSE client. That change should substantially reduce event-load amplification, but the clean six-client test stayed below 1% CPU, so per-client encoding does not explain this autonomous idle allocation loop.
Strongest remaining direction
Location runtimes expire after 60 minutes idle. Logs show synchronized cycles where location watchers stop, teardown emits agent.updated, command.updated, and catalog.updated, connected TUIs refetch those resources, and those requests reacquire and reboot recently expired locations.
A fresh process with all locations does not reproduce the issue, so the strongest OpenCode-specific hypothesis is lifecycle state accumulating across repeated expiry/reboot cycles, for example:
- Event listeners or plugin stream subscriptions not being removed.
- Failed or overflowing SSE listeners surviving until a request scope fully closes.
- Effect fibers surviving a finalized location generation.
- Process-global EventV2 consumers accumulating across generations.
- A session coordinator/runner fiber remaining runnable after durable execution appears complete.
The exact hot JavaScript function or Effect fiber is still unidentified because the compiled executable was stripped and did not emit JIT symbol mappings. The evidence supports a persistent allocation loop but does not yet establish which lifecycle component owns it.
Related but distinct: #36311.
Activity
dondetir commentedon Jul 22, 2026
Spent some time on this from the code side. Findings, in case they help narrow it down:
Ruled out with evidence:
agent.updated/command.updated/catalog.updatedemissions you observed at expiry are theState.createscope finalizers themselves (state.tsfinalize), so location scope close provably runs. Boot emits the same three events when the state nodes first materialize..updatedto refetch to reacquire cycle is client driven and quiesces once the RcMap entry is warm; a warm get is a cache hit.Still standing, and it matches your "state accumulated across expiry/reboot cycles" observation: subscriber accumulation on the two process global unbounded PubSubs in
packages/core/src/event.ts(pubsub.liveand the per type map, which is never pruned). Location scoped services subscribe to these global pubsubs from at least 8 call sites. One concrete path with no guaranteed unsubscribe is the plugin bridge inpackages/core/src/plugin/promise.ts, which hands pluginsStream.toAsyncIterable(events.subscribe()); if the async iterator is never returned/closed, that generation's subscriber survives on the global pubsub. At N leaked subscribers every published event fans out to N unbounded queues, which would show up exactly as steady JS allocation churn.If you can still reach a degraded server, dumping the subscriber counts of
pubsub.liveandpubsub.typedand comparing them against the number of live locations would confirm or kill that theory quickly.While digging I also found a separate correctness bug in the same lifecycle: a location whose boot fails once serves the cached failure for the full 60 minute TTL with no recovery path (production never calls
locations.invalidate). Filed as #38405 with a repro, fix in #38406.dondetir commentedon Jul 23, 2026
Following up on my earlier analysis: I can now reproduce the accumulation mechanism deterministically in about 4 seconds.
Setup: a promise plugin that consumes
ctx.event.subscribe()in a detachedfor awaitloop and never closes the iterator, which is a normal thing for a real plugin to do. Cycle a location through boot then invalidate (invalidate exercises the same entry scope close as the 60 minute idle expiry) and count subscribers on the process globalpubsub.liveinpackages/core/src/event.ts(the internalsubscribersset size).Results over 10 cycles:
Leak point:
packages/core/src/plugin/promise.tsline 93,subscribe: () => Stream.toAsyncIterable(host.event.subscribe().pipe(Stream.map(wireEvent))). The subscription's lifetime is owned by the async iterator, not the plugin generation, so when a location generation is torn down the plugin's subscriber survives on the global unbounded PubSub. Nothing closes it: the detached loop keeps pulling, so the subscription stays registered on the global PubSub for the life of the process even though its location generation is gone.This lines up with the report, with one caveat on what's actually reproduced: the harness measures subscriber accumulation, not CPU or RSS. The accumulation is deterministic. The rest follows from it: each leaked subscription stays live and its detached loop keeps draining, so every published event is delivered to and processed by all accumulated generations. The cost is per-event fan-out across N live subscriptions plus the retained fibers and iterators, not an undrained queue backlog. A fresh process has none of this, which is consistent with a clean run staying under 1 percent.
Fix direction I would propose: tie the promise bridge event subscription to the plugin generation scope so teardown interrupts the stream even when the plugin never calls
return()on the iterator. Happy to open a PR for this referencing this issue.Repro harness (test file plus a small fixture plugin) available on request; it is 60 lines against
packages/core/test.tiffanychum commentedon Jul 25, 2026
Re-triage note from PR #38825 (now closed):
dondetir’s deterministic repro depended on a Promise plugin detaching
for awaitonctx.event.subscribe(), which leaked one process-globalpubsubsubscriber per location boot→invalidate cycle.On current
dev, that API is gone. The namespaced plugin rewrite (909a1a6d7) replacedPluginContextwithout aneventsurface; Promise and Effect hosts no longer expose event subscribe. Remaining in-processEventV2subscribers in built-in plugins useEffect.forkScoped(scope-owned).So the concrete leak path from the comments is not reproducible through today’s plugin API. The broader “long-lived idle V2 server allocation loop” report may still be real via another path — please re-open investigation if it still occurs on a recent binary, ideally with subscriber counts on
pubsub.all/pubsub.typedvs live locations as dondetir suggested.Just-Silver commentedon Sep 25, 2026
Repro: plugin
setupcleanup is never called whenlocation.reloadhappens while the location is active (refCount > 0) — the old activation leaksThis looks like a separate leak path from the one #38825 addressed (promise-plugin
for awaitnot callingreturn()). Onv2(v2.0.15)ctx.event.subscribestill exists, so that path was not the cause here. The maintainer note on #38825 says "no reachable repro of subscriber accumulation across location invalidate/expiry" — this is one.Root cause
LocationServiceMap.reload()(packages/core/src/location-service-map.ts) callslocations.invalidate(ref)for every location. The custominvalidateinlocation-services.tsrunsinner.invalidate(key).pipe(Effect.andThen(build?.close)), whereinneris aLayerMapwithidleTimeToLive: Duration.infinity. Effect'sRcMap.invalidate:So when a location still has an outstanding borrower (e.g. a session turn is running), the old
Instancelayer scope is not closed.build.close(LocationLifecycle.shutdown) detaches routing and interrupts, but the Layer scope — and therefore the plugin activation'sEffect.acquireRelease(setup, cleanup)— is only released when refCount reaches 0. Ifreloadreplaces the entry, the old scope is orphaned andcleanupis never invoked.Minimal repro (trivial promise plugin)
A plugin whose
setuponly logs and returns a cleanup that aborts its event subscription:opencode reload.The old D activation (
e40c0o) is still alive and keeps receivingsession.execution.*from the global event stream after the reload. Idle locations are cleaned up correctly; only the active one leaks.I confirmed the inverse as well: when the cleanup is called,
abort.abort()stops the loop (0 further events); when a probe omitsabort(), the old loop keeps receiving events. So the plugin-side teardown is fine — the host simply never calls it in this case.Impact
/api/eventstream and passes the same ownership filter, so onesession.execution.succeededgets delivered N times → N× auto-continuation. Measured ratio (auto-continue receipts / succeeded) went from1to3–4after a few reloads while a turn was in flight.ctx/ location services, so memory only grows — same class of accumulation as this issue.Environment
opencode
v2.0.15(v2 branch), Windows, reproduced over the HTTP API withopencode serve.The ask:
LocationServiceMap.reload()should force-close (or wait for) the previous location scope even when it still has borrowers, instead of orphaning it. Happy to share the full probe script and logs.underdark456 commentedon Oct 1, 2026
Hit the same thing @Just-Silver describes, on 2.0.20 with
opencode serve --serviceand the desktop app on macOS. For me it shows up as leaked MCP processes. I never got the CPU loop.After about a day I had 692 processes under the service and ~39 full sets of my 12 MCP servers. Every local MCP gets spawned with cwd = location directory, so I grouped the processes by cwd. Most dirs had one set. The dir I actually work in had 8.
Lining that up with the log:
location services evictedthe old set gets killed, and the app reboots the location within ~2s.location services reloaded count=31leaks. It fires every time I editopencode.json. Over the day there were 8 of those reloads and my working dir picked up 7 extra sets. Every surviving set started right before one of them. My guess is the location was still in use each time.Same
RcMap.invalidatepath as above. WithrefCount > 0the old scope never closes, and the stdio MCP clients live in that scope too.The part that surprised me is that the orphaned graphs aren't inert. I killed all the stale MCP processes, then changed a single timeout field on one MCP server. This time there was no
location services reloaded, only that server reconnecting per location. 9 fresh duplicates of it showed up in exactly the dirs where I had killed orphans. So the detached graphs keep watching config and respawning their MCP clients until the server restarts.Side effects for me were up to 74 duplicate
kubectl port-forwards from a k8s MCP, and a screenshot MCP daemon whose session registry filled up and stopped working.Current workaround is a launchd job that dedupes MCP children of the service every 15 min, keyed by cwd plus argv and env. Env matters because two of my servers run the same binary with different env. That cleans up the processes, but the graphs stay in memory until
opencode service restart.+1 to the ask:
reload()should close the previous location scope, or interrupt its borrowers like the idle sweep does, instead of orphaning it.