#4159 · Mutable plugin ESM generations remain retained

Bug · High priority · High effort · plugins, perf · 2026-09-23 · GitHub issue

Trusted base: 94d77da09568d7f0f7cdb23b84686e743acfb0ad. Verdict: PARTIALLY REPRODUCED. Root-cause confidence: high for native ESM retention.

1. TL;DR

Reloading a mutable native ESM graph leaves earlier module state reachable after garbage collection. An isolated reproduction using the current repository’s resolve-hook and generation functions retains eight distinct module states across eight loads, even after root deregistration. The control retains only one state. The reported large TypeScript graph, Electron crash, and 73 MB rate were not reproduced; a tiny TypeScript graph under Node 22 does not show the same retention. No fix PR is proposed because changing module reuse or execution isolation needs a lifecycle design decision.

2. Claims vs findings

ClaimFindingEvidence
Fresh module URLs accumulate retained graphsVerified for native ESMTwo runs: 16.36 MiB heap growth after eight loads, eight distinct retained arrays; control about 2.2 MiB and one array.
TypeScript loads retain about 73 MB each in newer runtimesUnverifiedNode 22 tiny TypeScript fixture retains zero exported arrays; no external plugin downloaded and no Node 24/26 run.
Disable/enable takes the same loading pathVerified staticallyBoth enable and reload call loadOne. Full HTTP/service cycles were not exercised.
Path sources select source entry even with a built artifactVerified staticallyresolveServerEntry returns manifest.serverEntry for path sources.
Production server crashes after many reloadsUnverifiedNo crash was induced and no production data accessed.

3. Environment

macOS arm64, Node v22.22.3, repository-locked jiti 2.7.0. Initial checkout at trusted origin/main, followed by a separate detached clean worktree at the same commit. Frozen dependency installations succeeded with Corepack. Full Turbo build succeeded (60 tasks); the second checkout’s server build also succeeded. The first launcher attempt failed because the local pnpm launcher referenced a missing installation; a temporary wrapper invoking Corepack resolved it. No server, provider, port, real account, or persistent application data was used. Each invocation creates and removes its own temporary fixture directory.

4. Minimal reproduction

Save retention.mjs outside a trusted clone and run the following from that clone. The harness extracts the actual hook and generation functions directly from source, strips their TypeScript annotations, and imports through the repository’s jiti with moduleCache disabled. It does not import the whole service. The fixture has no SDK imports, so no SDK alias is needed.

git checkout 94d77da09568d7f0f7cdb23b84686e743acfb0ad
corepack pnpm install --frozen-lockfile --prefer-offline
corepack pnpm exec turbo run build --filter=@bb/server
node --expose-gc /path/to/retention.mjs "$PWD" generation
node --expose-gc /path/to/retention.mjs "$PWD" control
node --expose-gc /path/to/retention.mjs "$PWD" generation --typescript
node --expose-gc /path/to/retention.mjs "$PWD" generation --assert-reclaimed

Each module exports a 262144-element array. The harness keeps only WeakRefs between loads, performs eight GCs across event-loop turns per sample, then deregisters the root and collects again. Expected bounded behavior: obsolete arrays can be collected. Actual ESM behavior: all eight arrays survive. The final command fails with 8 !== 1, providing an explicit desired-behavior regression assertion; it is a focused loader test, not an end-to-end plugin-service regression test. Its default mode asserts the observed current behavior and exits successfully.

import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { createRequire, stripTypeScriptTypes } from 'node:module';
import { pathToFileURL } from 'node:url';
import assert from 'node:assert/strict';

const checkout = resolve(process.argv[2]);
const mode = process.argv[3] ?? 'generation';
const typescript = process.argv.includes('--typescript');
const extension = typescript ? 'ts' : 'js';
const source = readFileSync(join(checkout, 'apps/server/src/services/plugins/plugin-runtime.ts'), 'utf8');
const first = source.slice(source.indexOf('interface MutableRoot {'), source.indexOf('const PROVIDER_ICON_CONTENT_TYPES'));
const second = source.slice(source.indexOf('function mutableRootDir('), source.indexOf('type PluginDevBuildKind'));
const scratch = mkdtempSync(join(tmpdir(), 'bb-retention-'));
const loader = join(scratch, 'loader.mjs');
writeFileSync(loader, stripTypeScriptTypes(`import { realpathSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { createRequire, registerHooks } from 'node:module';\n${first}\n${second}\nexport { bumpMutableRootGeneration };`, { mode: 'strip' }));
const { bumpMutableRootGeneration, forgetMutableRoot } = await import(pathToFileURL(loader).href);
const require = createRequire(join(checkout, 'apps/server/package.json'));
const { createJiti } = require('jiti');
const root = join(scratch, 'fixture');
const { mkdirSync } = await import('node:fs');
mkdirSync(root);
writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' }));
writeFileSync(join(root, `state.${extension}`), `export const state${typescript ? ': number[]' : ''} = Array.from({length: 262144}, (_, i) => i);\n`);
writeFileSync(join(root, `server.${extension}`), `import { state } from "./state.${extension}";\nexport default function plugin() { return state; }\n`);
const refs = [];
async function gc() {
  for (let i = 0; i < 8; i++) {
    await new Promise(resolve => setImmediate(resolve));
    global.gc();
  }
}
async function load() {
  if (mode === 'generation') bumpMutableRootGeneration(root);
  const jiti = createJiti(pathToFileURL(join(checkout, 'apps/server/src/services/plugins/plugin-runtime.ts')).href, { moduleCache: false });
  const mod = await jiti.import(join(root, `server.${extension}`));
  const state = mod.default();
  refs.push(new WeakRef(state));
}
try {
  console.log(JSON.stringify({ node: process.version, platform: process.platform, arch: process.arch, mode, typescript }));
  await gc();
  const baseline = process.memoryUsage().heapUsed;
  for (let i = 0; i < 8; i++) {
    await load();
    await gc();
    console.log(JSON.stringify({ load: i + 1, heapGrowthMiB: Number(((process.memoryUsage().heapUsed - baseline) / 1048576).toFixed(2)) }));
  }
  forgetMutableRoot(root);
  await gc();
  const live = refs.map(ref => ref.deref()).filter(Boolean);
  const unique = new Set(live).size;
  console.log(JSON.stringify({ afterForget: true, liveReferences: live.length, uniqueRetainedStates: unique }));
  assert.equal(unique, typescript ? 0 : mode === 'generation' ? 8 : 1);
  if (process.argv.includes('--assert-reclaimed')) assert.equal(unique, 1, 'Disposed generations should not retain all prior module states');
} finally {
  forgetMutableRoot(root);
  rmSync(scratch, { recursive: true, force: true });
}

5. Root cause

The resolver attaches a root ID and monotonically increasing generation to file URLs. Generation management evicts CommonJS cache entries but produces a fresh URL namespace on every load. loadOne advances mutable generations and imports using jiti. Native ESM loading consequently creates new module records rather than reusing prior ones. Deregistering the resolve hook stops future rewriting but does not undo already imported ESM modules; the WeakRef experiment demonstrates their state remains alive.

Disposal tears down plugin resources and removes loaded handles; it has no ESM-registry eviction operation. Enable and reload both reach the same loadOne path. Entry selection directly selects the manifest entry for path installations.

epoch: nextMutableRootEpoch++
url: `${resolved.url}${separator}bbPluginLoad=${match.id}.${epoch}`
const jiti = createJiti(import.meta.url, { moduleCache: false, ... });
const mod = await jiti.import(serverEntry);

6. Proposed fix and simple-fix gate

Investigate terminating a dedicated execution context to release a server plugin’s entire module graph. That requires deciding how existing in-process SDK functions, services, callbacks, and registrations cross the boundary. Content-based reuse would need explicit module-state semantics and transitive dependency invalidation: an unchanged importer can otherwise keep its old imported dependency. A built artifact can reduce retained size but cannot release old native ESM generations. No safe local change was identified that both fixes the root cause and avoids architecture or public-boundary decisions. No production files were changed, no fix branch was pushed, and no PR was opened. GitHub cross-references and an open-PR search returned no linked open PR.

7. Verification

The same agent created a second clean detached checkout at the recorded SHA, installed frozen dependencies, built the server, and ran the three generation/control/TypeScript commands above in fresh processes. The second ESM run grew from 2.10 to 16.36 MiB and retained eight distinct arrays; the control grew from 2.09 to 2.22 MiB and retained one. The TypeScript run grew from 8.58 to 8.81 MiB and retained zero arrays. The report’s scope was narrowed after the initial TypeScript experiment: this is a partial reproduction of the issue, not verification of its newer-runtime TypeScript amplification or crash. This is repeat verification by the same agent, not independent review.

8. Related issues

A repository search for memory/reload did not identify a duplicate of this loader retention mechanism.

9. Appendix

Issue material was treated only as untrusted claims. No linked external code or scripts were fetched or executed. All executable fixture code was authored from trusted repository evidence. Logs below are direct process output; the expected-failure stack path is redacted. No screenshots are appropriate for this nonvisual loader test.

expected-failure.log

{"node":"v22.22.3","platform":"darwin","arch":"arm64","mode":"generation","typescript":false}
(node:17929) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{"load":1,"heapGrowthMiB":2.1}
{"load":2,"heapGrowthMiB":4.13}
{"load":3,"heapGrowthMiB":6.17}
{"load":4,"heapGrowthMiB":8.21}
{"load":5,"heapGrowthMiB":10.24}
{"load":6,"heapGrowthMiB":12.28}
{"load":7,"heapGrowthMiB":14.31}
{"load":8,"heapGrowthMiB":16.36}
{"afterForget":true,"liveReferences":8,"uniqueRetainedStates":8}
node:internal/modules/run_main:123
    triggerUncaughtException(
    ^

AssertionError [ERR_ASSERTION]: Disposed generations should not retain all prior module states

8 !== 1

    at file:///REPORT/retention.mjs:56:59 {
  generatedMessage: false,
  code: 'ERR_ASSERTION',
  actual: 8,
  expected: 1,
  operator: 'strictEqual',
  diff: 'simple'
}

Node.js v22.22.3

first-control.log

{"node":"v22.22.3","platform":"darwin","arch":"arm64","mode":"control"}
(node:16594) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{"load":1,"heapGrowthMiB":2.09}
{"load":2,"heapGrowthMiB":2.1}
{"load":3,"heapGrowthMiB":2.11}
{"load":4,"heapGrowthMiB":2.12}
{"load":5,"heapGrowthMiB":2.14}
{"load":6,"heapGrowthMiB":2.15}
{"load":7,"heapGrowthMiB":2.16}
{"load":8,"heapGrowthMiB":2.21}
{"afterForget":true,"liveReferences":8,"uniqueRetainedStates":1}

first-generation.log

{"node":"v22.22.3","platform":"darwin","arch":"arm64","mode":"generation"}
(node:16577) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{"load":1,"heapGrowthMiB":2.1}
{"load":2,"heapGrowthMiB":4.13}
{"load":3,"heapGrowthMiB":6.17}
{"load":4,"heapGrowthMiB":8.21}
{"load":5,"heapGrowthMiB":10.24}
{"load":6,"heapGrowthMiB":12.28}
{"load":7,"heapGrowthMiB":14.31}
{"load":8,"heapGrowthMiB":16.36}
{"afterForget":true,"liveReferences":8,"uniqueRetainedStates":8}

first-typescript.log

{"node":"v22.22.3","platform":"darwin","arch":"arm64","mode":"generation","typescript":true}
(node:17927) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{"load":1,"heapGrowthMiB":8.59}
{"load":2,"heapGrowthMiB":8.61}
{"load":3,"heapGrowthMiB":8.66}
{"load":4,"heapGrowthMiB":8.66}
{"load":5,"heapGrowthMiB":8.72}
{"load":6,"heapGrowthMiB":8.73}
{"load":7,"heapGrowthMiB":8.77}
{"load":8,"heapGrowthMiB":8.83}
{"afterForget":true,"liveReferences":0,"uniqueRetainedStates":0}

second-control.log

{"node":"v22.22.3","platform":"darwin","arch":"arm64","mode":"control","typescript":false}
(node:18093) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{"load":1,"heapGrowthMiB":2.09}
{"load":2,"heapGrowthMiB":2.1}
{"load":3,"heapGrowthMiB":2.11}
{"load":4,"heapGrowthMiB":2.12}
{"load":5,"heapGrowthMiB":2.14}
{"load":6,"heapGrowthMiB":2.15}
{"load":7,"heapGrowthMiB":2.17}
{"load":8,"heapGrowthMiB":2.22}
{"afterForget":true,"liveReferences":8,"uniqueRetainedStates":1}

second-generation.log

{"node":"v22.22.3","platform":"darwin","arch":"arm64","mode":"generation","typescript":false}
(node:18092) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{"load":1,"heapGrowthMiB":2.1}
{"load":2,"heapGrowthMiB":4.13}
{"load":3,"heapGrowthMiB":6.18}
{"load":4,"heapGrowthMiB":8.22}
{"load":5,"heapGrowthMiB":10.25}
{"load":6,"heapGrowthMiB":12.28}
{"load":7,"heapGrowthMiB":14.31}
{"load":8,"heapGrowthMiB":16.36}
{"afterForget":true,"liveReferences":8,"uniqueRetainedStates":8}

second-typescript.log

{"node":"v22.22.3","platform":"darwin","arch":"arm64","mode":"generation","typescript":true}
(node:18094) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
{"load":1,"heapGrowthMiB":8.58}
{"load":2,"heapGrowthMiB":8.61}
{"load":3,"heapGrowthMiB":8.66}
{"load":4,"heapGrowthMiB":8.66}
{"load":5,"heapGrowthMiB":8.72}
{"load":6,"heapGrowthMiB":8.73}
{"load":7,"heapGrowthMiB":8.77}
{"load":8,"heapGrowthMiB":8.81}
{"afterForget":true,"liveReferences":0,"uniqueRetainedStates":0}

> AGENT GENERATED