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:
|
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:
|
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:
|
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:
|
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:
|
Save this alongside it as /tmp/opencode-esm-repro/repro.mjs:
|
Run each mode in a fresh process:
|
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:
- 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.
- 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.
- 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.
- Detach Location listeners on disposal. Otherwise, the shared cache could itself retain a closed Location's watcher callback and host context.
- 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 |
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:
|
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.