Skip to content

Memory leak: module-level seen cache in experimental mutators pins every mutated program's type-graph for the process lifetime #11120

Description

@xirzec

Summary

The experimental mutator engine (packages/compiler/src/experimental/mutators.ts) holds a module-level, never-cleared, strongly-referenced cache named seen. Every call to mutateSubgraph / mutateSubgraphWithNamespace inserts Type values (both originals and clones) into this cache, and the cache is never cleared. As a result, the entire type-graph of every program that is ever mutated stays pinned in memory for the lifetime of the process.

When a host process compiles + emits many programs in the same process (e.g. running an emitter's unit test suite, or any tool that drives compile() in a loop), heap usage grows without bound (~32 MB per versioned program in my repro) and eventually OOMs.

This is the upstream root cause of Azure/typespec-azure#4536 ("[typespec-ts] Emitter is leaking memory between calls"). It affects all emitters that trigger versioning mutation via TCGC's createSdkContext (e.g. @azure-tools/typespec-ts, @azure-tools/typespec-autorest), but the bug itself is entirely in the core compiler and is independent of any Azure code.

Root cause

packages/compiler/src/experimental/mutators.ts:

const typeId = CustomKeyMap.objectKeyer();
const mutatorId = CustomKeyMap.objectKeyer();
const seen = new CustomKeyMap<[MutableTypeWithNamespace, Set<Mutator> | Mutator[]], Type>(
  ([type, mutators]) => {
    const key = `${typeId.getKey(type)}-${[...mutators.values()]
      .map((v) => mutatorId.getKey(v))
      .join("-")}`;
    return key;
  },
);

Three properties combine to make this a permanent leak:

  1. seen is module scope and never cleared. grep for seen.clear / seen.delete / seen = finds only the declaration. Entries live as long as the module (i.e. the process).
  2. CustomKeyMap is backed by a strong Map. From packages/compiler/src/utils/custom-key-map.ts:
    export class CustomKeyMap<K extends readonly any[], V> {
      #items = new Map<string, V>();   // strong refs to V (= Type) forever
      ...
    }
    The stored values are Type objects — clones in the general case, and the original input type in the "no mutation needed" branch (seen.set([type, activeMutators], type)). Holding any Type strongly pins its whole program graph (namespace links, SymbolTable, checker TypeInstantiationMap, etc.).
  3. Keys never collide or overwrite across programs. CustomKeyMap.objectKeyer() assigns ids from a let count = 0 that increments per process and never resets. Types from a second program get fresh ids, so their keys never match the first program's keys — entries purely accumulate, they are never replaced.

Because the realm/clone identity is per top-level mutate call (each createMutatorEngine makes a fresh Realm), sharing seen across calls isn't just a leak — returning a clone that belongs to a previous realm would also be a latent correctness hazard.

Why it only reproduces on some paths

The leak is only exercised when something actually drives the mutator engine. The most common trigger is versioning: @azure-tools/typespec-client-generator-core's createSdkContext calls handleVersioningMutationForGlobalNamespace, which calls getVersioningMutators + unsafe_mutateSubgraphWithNamespace. So:

  • Plain compile() with noEmit → mutator engine never runs → flat.
  • createSdkContext on a non-versioned spec → no versioning mutation → flat.
  • createSdkContext on a @versioned spec → versioning mutation runs → ~32 MB pinned per program.

This also explains a confusing observation downstream: an emitter's modular unit path looked flat because those fixtures were mostly non-versioned, while the production/integration path (versioned service specs) leaked.

Reproduction

Minimal in-process loop (Node with --expose-gc): compile + createSdkContext (or run any emitter that does) over a @versioned service spec N times, calling global.gc() twice between iterations and recording process.memoryUsage().heapUsed. The post-GC floor is the signal (RSS sawtooth is not).

A pure-compiler repro without any Azure packages: call unsafe_mutateSubgraphWithNamespace(program, versioningMutators, globalNamespace) in a loop over freshly compiled versioned programs and watch the post-GC floor climb.

Empirical evidence (A/B on identical input)

Same @versioned service spec, 8 in-process emit iterations, forced GC each iteration, post-GC heapUsed floor:

Build iter 0 floor iter 7 floor per-iter growth
Unpatched 53.7 MB 280.1 MB ~32 MB/iter, linear, unbounded
seen cleared after each top-level mutate 53.4 MB 88.4 MB flat after warmup (~0.4 MB/iter)

Heap-snapshot diffs on the unpatched build show the growth dominated by retained object::SymbolTable, object::RekeyableMapImpl, object::TypeInstantiationMap and closure::getMappedType (checker.js) — i.e. whole prior programs pinned, consistent with the seen analysis.

Recommended fix

Make seen (and the typeId / mutatorId keyers it depends on) per-engine instead of module-level — move them inside createMutatorEngine, so each top-level mutateSubgraph / mutateSubgraphWithNamespace call gets its own cache that becomes collectable as soon as the call's realm is released. This:

  • bounds retention to the lifetime of a single mutation (no cross-program accumulation), and
  • removes the cross-realm clone-identity hazard.

A minimal/低-risk alternative is to seen.clear() at the end of mutateSubgraph and mutateSubgraphWithNamespace (verified to flatten the floor in the table above), but per-engine scoping is the cleaner, more correct change. If any caller actually depends on cross-call clone identity, that contract should be made explicit rather than relying on an unbounded global cache.

(Whatever the fix, please add a regression test that loops the mutator over multiple programs and asserts the post-GC heap floor stays bounded.)

Environment

  • @typespec/compiler 1.13.0
  • core commit 37199629393b92015668f107b3ac2502ca734f53
  • Node v22

Downstream reference

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingcompiler:coreIssues for @typespec/compiler

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions