Eric Guo's blog.cloud-mes.com

Hoping writing JS, Ruby & Rails and Go article, but fallback to DevOps note

Fixing an OpenCode Memory Leak Hidden in Node.js's ESM Cache

Permalink

My OpenCode background server had reached a physical footprint of about 1.1 GiB. Investigating it with Codex led to a surprisingly small piece of code: a plugin loader that forgot its source fingerprints whenever a Location was disposed.

Node.js remembered the imported modules. OpenCode forgot that it could reuse them. The next Location imported the same files under fresh URLs, retaining another copy of the plugin and its dependency graph.

After applying the fix, the server's reported process footprint was 493.5 MB. A separate reproduction that repeatedly created and disposed the loader stayed around 5.4 MiB of heap after 40 loads; the original implementation reached 35.57 MiB. This post walks through the evidence, the lifetime mistake, and the tests that made the repair convincing.

Start with the measurements, then find the owner

The investigation was captured on September 14, 2026, on an Apple Silicon Mac running macOS 26.6.2. The target was my OpenCode V2 development server, running as a background service. This is an account of that build and its local plugin loader.

Before taking a heap snapshot, the measurements were:

Measurement Observed value
Physical footprint from vmmap -summary About 1.1 GiB
Peak physical footprint About 1.8 GiB
RSS from ps 1,218,432 KiB
Resident V8-associated Memory Tag 255 regions 832.1 MiB

These numbers describe overlapping views of the same process. Adding them together would produce a meaningless total. A large footprint also does not establish how much memory is leaking: the executable, assets, ordinary module caches, and allocator behavior all contribute.

This build already had a SIGUSR1 heap-snapshot handler. For the historical process, PID 57305, the diagnostic commands were:

Commands used for the investigated process
ps -p 57305 -o pid,rss,etime,command
vmmap -summary 57305
kill -USR1 57305

The signal command depended on OpenCode's installed handler; it is not a generic instruction for taking a snapshot of an arbitrary Node process. The handler wrote a .heapsnapshot file into OpenCode's log directory without attaching a debugger or requiring sudo.

The snapshot took roughly 4.7 seconds and contained 3.82 million nodes and 24.62 million edges. Its shallow node sizes summed to 413.39 MiB. That total includes native backing stores represented in the graph, so I kept it separate from both RSS and a live process.memoryUsage().heapUsed reading.

Snapshotting also affected the process: RSS temporarily rose to about 2.1 GiB, and the footprint peak reached 2.8 GiB. Node documents that heap snapshot generation blocks execution and needs substantial additional memory. Those diagnostic peaks could not be used as evidence of normal application growth.

The useful question was which objects still had strong owners.

Fifteen files, 840 module jobs

The module loader dominated 208.02 MiB in the heap graph, including a 206.44 MiB load cache. Some of that was ordinary cached code. The unusual part was the repeated local plugin graph:

  • One plugin entrypoint appeared 56 times, each with a different ?__opencode_reload= query parameter.
  • Fourteen local dependency files also appeared 56 times each.
  • That made 840 module jobs for 15 physical files.

Among the retained objects were 448 substantial tool definitions, including their argument and output Zod schema graphs. Together, those objects retained 130.29 MiB. This was part of the module-cache retention, not another amount to add to it.

A representative retaining path looked like this:

The strong reference chain keeping plugin definitions alive
GC roots
→ global handle
→ native_bind
→ ModuleLoader
→ LoadCache
→ table
→ ModuleJob
→ ModuleWrap
→ SourceTextModule
→ export cell
→ tool definition
→ Zod schemas

This explained why disposing application services had not released those definitions. A strong reference chain still ran through the runtime's module loader.

Other large objects had more ordinary explanations: approximately 51.67 MiB of bundled program source, 8.37 MiB of embedded web asset text, and 6.96 MiB of model snapshot text. Their size alone did not show that they were accumulating.

Cleanup was running

OpenCode's Location groups services associated with a project directory. Its plugin activation and filesystem subscriptions belong to that Location. In this build, inactive Locations were eligible for eviction after 60 minutes.

The logs for the same process showed the following sequence. Times are UTC+08:00:

Event Time Count
Initial plugin loads 17:08–17:10 28
Location service evictions 18:17 28
Plugin loads following those evictions 18:17 28
Second round of Location evictions 19:50 28
Generations still present in the heap snapshot 21:11 56

The eviction logs mattered: cleanup had happened. The exact client requests that caused the Locations to be rebuilt were not established, but the repeated imports and retained generations were visible.

Tracing the loader revealed two different lifetimes:

Resource Lifetime before the fix
Source fingerprints and remembered import attempts One Location
Plugin activation and watchers One Location
Evaluated Node ESM modules Retained by the runtime's module cache

Disposing a Location cleared the first row and cleaned up the second. It did not remove the third.

How cache busting became accumulation

The loader used a generation parameter to reload edited local code. The following is a simplified illustration; the actual Node implementation also propagated the generation to local dependencies through a resolver hook:

Simplified generation-based module loading
const url = new URL(entrypoint)
url.searchParams.set("__opencode_reload", String(++generation))
const module = await import(url.href)

Node caches ESM modules by URL. Different query strings or fragments can therefore produce separate module loads. Its ESM cache is also separate from require.cache. Both behaviors are documented in the ESM URL rules and the section on the separate ESM cache.

Within one createPluginSources() instance, OpenCode remembered the files and their content fingerprints. Reading an unchanged graph reused the earlier import attempt. That worked while the Location stayed alive.

When the Location was disposed, its source map was cleared. The next Location started with an empty map, prepared a new generation, and imported another copy. Deregistering the old resolver hook stopped that hook from participating in resolution; it did not undo the modules already evaluated through it.

The result was a repeatable sequence:

Unchanged source acquired a new module identity after every recreation
Create Location A → import plugin.mjs?__opencode_reload=1
Dispose Location A → application source map is cleared
Create Location B → import plugin.mjs?__opencode_reload=2
Dispose Location B → application source map is cleared
Node's module cache still retains both generations.

The dependency graph amplified the cost. Each unnecessary preparation could duplicate all 15 local modules, including exported tool definitions and their schema objects.

Reproduce it without the application

To isolate the mechanism, the reproduction bundled the repository's real packages/plugin/src/source.ts with Node conditions. The fixture exported a single 100,000-element array. It made no network requests and used no database, sessions, or application services.

Ordinary Node 26.8.2 reproduced the growth. OpenCode's single-executable packaging was not required.

The initial experiment compared recreating the source cache with keeping one source cache alive:

Loads Recreate and dispose: heap MiB Reuse one cache: heap MiB
0 4.39 4.39
10 12.51 5.40
20 20.19 5.40
30 27.89 5.41
40 35.57 5.41

Each sample yielded to the event loop and ran two explicit garbage collections. The shared-cache control stayed flat; the recreated-cache case retained approximately one fixture payload per cycle.

Here is a portable version of the reproduction. From an OpenCode checkout with dependencies installed, first build the actual loader:

Bundle the plugin loader using Node conditions
mkdir -p /tmp/opencode-esm-repro
bun build packages/plugin/src/source.ts \
--target=node \
--outfile=/tmp/opencode-esm-repro/source.mjs

Save this alongside it as /tmp/opencode-esm-repro/repro.mjs:

Compare recreated and shared source caches
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { createPluginSources } from "./source.mjs"
if (!global.gc) throw new Error("Run Node with --expose-gc")
const directory = await mkdtemp(path.join(tmpdir(), "opencode-esm-"))
const file = path.join(directory, "plugin.mjs")
await writeFile(file, "export const payload = new Array(100_000).fill(123456)")
const entrypoint = pathToFileURL(file).href
const mode = process.argv[2] ?? "recreated"
const shared = mode === "shared" ? createPluginSources(async () => {}) : undefined
const samples = []
for (let loads = 0; loads <= 40; loads++) {
if (loads > 0) {
const sources = shared ?? createPluginSources(async () => {})
await sources.read(entrypoint)
if (!shared) sources.dispose()
}
if (loads % 10 === 0) {
await new Promise(resolve => setImmediate(resolve))
global.gc()
global.gc()
samples.push({
loads,
heapMiB: Number((process.memoryUsage().heapUsed / 1024 ** 2).toFixed(2)),
})
}
}
shared?.dispose()
console.log(JSON.stringify({ node: process.version, mode, samples }, null, 2))
await rm(directory, { recursive: true, force: true })

Run each mode in a fresh process:

Measure both lifetimes with explicit garbage collection
node --expose-gc /tmp/opencode-esm-repro/repro.mjs recreated
node --expose-gc /tmp/opencode-esm-repro/repro.mjs shared

To compare implementations, build the loader from each revision in turn and rerun the same commands. The fix commit is 25fdc20a5f; its parent contains the previous loader. With the fixed loader, both modes should plateau. Exact heap values will vary between runs and environments.

Match the cache lifetime to module evaluation

The fix keeps the latest source evaluation for each entrypoint in a process-wide map. Each entry records the import promise, dependency fingerprints, resolver cleanup, and the Locations currently listening for discovered dependencies.

The ownership split is now:

Process-wide module reuse Per-Location resources
Latest import attempt for an entrypoint Plugin setup and its host context
Fingerprints of tracked local dependencies Tool, command, and RPC registrations
Resolver needed by the current graph Watcher subscriptions and pending watcher setup

This shares the evaluated plugin definition. Each Location still activates that definition with its own host services and options, then runs its own cleanup when it closes.

The source reader compares the tracked file digests before reusing a graph. Editing a helper file therefore causes a new generation even when the entrypoint is unchanged. Edits made while no Location is active are detected on the next read.

Several details make this more than moving a Map outside a function:

  1. Publish the attempt before preparing it. Preparation and evaluation begin in a deferred promise after the shared entry exists. Concurrent readers of unchanged source join the same attempt.
  2. Remember failed evaluations too. Unchanged broken modules should not repeat import-time side effects on every notification or Location recreation. A changed fingerprint permits another attempt.
  3. Subscribe each new Location to the known dependencies. Reusing a module must still establish that Location's watchers. Dependencies discovered later, through lazy imports, are forwarded to the current listeners.
  4. Detach Location listeners on disposal. Otherwise, the shared cache could itself retain a closed Location's watcher callback and host context.
  5. Keep watcher readiness local. A Location waits for its own watcher setup. One Location's pending watcher must not block another Location from using the same evaluated module.

Resolver cleanup needs the same distinction. The latest cached graph keeps its resolver available for later lazy imports, even when no Location is active. A superseded graph releases its resolver once evaluation has finished and no Location still subscribes to it.

An in-flight import also does not automatically make a graph reusable: a reader still checks the fingerprints. If a dependency changes while the earlier evaluation is pending, that changed graph can get a new attempt.

The complete implementation and regression tests are in the patch.

Verify memory and lifecycle behavior

After applying the fix to the actual server, I observed a process footprint of 493.5 MB, compared with the approximately 1.1 GiB physical footprint recorded during the original investigation. I have kept the units as reported. This later live-server observation and the controlled loader experiment measure different things: the former includes the whole application, while the latter isolates the retention mechanism.

Running the recreated-cache reproduction against the patched loader produced this comparison with the original loader:

Loads Before: heap MiB After: heap MiB
0 4.39 4.39
10 12.51 5.41
20 20.19 5.42
30 27.89 5.42
40 35.57 5.43

Heap usage after garbage collection over 40 loader recreation cycles. The original loader grows from 4.39 to 35.57 MiB; the fixed loader rises initially and then stays near 5.4 MiB.

Both lines recreate and dispose the source loader on every load. Measurements use the synthetic array fixture on Node 26.8.2, with two explicit garbage collections per sample.

The plateau was supported by direct behavior tests. Repeated disposal and recreation returned the same module object and version, while dependency edits produced a new version and updated exports. Tests also covered concurrent reads, failed imports, missing dependencies that later appear, lazy imports, closed watchers, and edits during pending evaluation.

The core integration test exercised actual Location creation and eviction. Two directories used the same plugin, and the first Location was recreated after eviction. The expected lifecycle was:

One evaluation, three independent activations
evaluate
setup first Location
cleanup first Location
setup second Location
cleanup second Location
setup first Location again
cleanup first Location again

Each activation also registered a command whose description came from its own Location directory. That checked that module reuse preserved the correct host context.

Validation passed 45 Bun tests, including the plugin and core lifecycle suites, plus 8 source-loader regressions under Node. The repository's full bun run check passed lint and all 37 type-check tasks.

What this fix does and does not establish

The repair stops unchanged local plugin graphs from accumulating merely because Locations are created, evicted, and recreated. The 40-cycle experiment demonstrates that specific mechanism; it does not measure the memory savings of an entire live OpenCode server.

Actual source edits still create new ESM generations under the existing reload strategy. Supporting indefinitely many edits with reclaimable module memory would require another design, such as a disposable worker or process, or a deliberate restart policy. This patch does not introduce that execution model.

Likewise, the new cache cannot retroactively release the old server's retained generations. Running a rebuilt server and restarting the existing process is needed to apply the fix and clear its previous module cache. The original heap investigation and synthetic measurements were completed before the later live-server footprint observation.

The raw heap snapshot stayed local because it contained application data. The small fixture was enough to share the mechanism and verify the repair.

What I will carry into the next investigation is the ownership question: after cleanup finishes, which longer-lived object can still reach the data? Here, following that chain explained both the retained memory and why the existing cleanup code had appeared to work.

Comments