Fix memory leak in experimental mutator engine's seen cache#11121
Queued
Copilot wants to merge 1 commit into
Queued
Fix memory leak in experimental mutator engine's seen cache#11121Copilot wants to merge 1 commit into
Copilot wants to merge 1 commit into
Conversation
Copilot
AI
changed the title
[WIP] Fix memory leak in experimental mutator engine
Fix memory leak in experimental mutator engine's seen cache
Jun 30, 2026
xirzec
approved these changes
Jun 30, 2026
commit: |
|
You can try these changes here
|
9364203 to
7e1949f
Compare
Contributor
|
All changed packages have been documented.
Show changes
|
timotheeguerin
approved these changes
Jul 2, 2026
timotheeguerin
requested changes
Jul 2, 2026
timotheeguerin
left a comment
Member
There was a problem hiding this comment.
Actually didn't realized the test was added there
The experimental mutator engine kept its seen memoization cache at module scope, which pinned the type graph of every mutated program in memory for the lifetime of the process. Scope the cache per Program via a WeakMap so it is released once the program is garbage collected, while still sharing it across the nested mutateSubgraph calls that recursive type graphs need to terminate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
306af47 to
7f8d372
Compare
timotheeguerin
approved these changes
Jul 2, 2026
Any commits made after this event will not be merged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The experimental mutator engine (
packages/compiler/src/experimental/mutators.ts) holds a module-level, never-cleared, strongly-referencedseencache. EverymutateSubgraph/mutateSubgraphWithNamespacecall insertsTypevalues into it, pinning the entire type graph of every mutated program in memory for the process lifetime — heap grows unbounded for hosts that drivecompile()in a loop (e.g. emitter test suites, versioning mutation via TCGC).Why the cache can't simply be scoped per-engine
The obvious fix — moving the cache into
createMutatorEngineso it dies with the engine — breaks recursive type graphs. Mutators such as@typespec/http's merge-patch transform callmutateSubgraphre-entrantly from inside their ownmutatefunction, and each call spins up a fresh engine. Theseencache must be shared across those nested engines so that a self-referential type (e.g.model Resource { related?: Record<Resource> }) is cloned once instead of recursing forever. A per-engine cache regresses this intoRangeError: Maximum call stack size exceeded(observed in the@typespec/httpmerge-patch and@typespec/samplesvisibility suites).The fix: scope the cache per
ProgramThe
seencache is now stored in aWeakMap<Program, SeenCache>instead of a single module-level map. Each program gets its own cache, so:Typeit references — is only reachable through theProgramkey. Once the program is garbage collected, its cache goes with it, instead of accumulating for the process lifetime. Hosts that compile in a loop no longer grow the heap unboundedly.mutateSubgraphcalls a mutator makes, so recursive/self-referential type graphs are cloned once and terminate.The type/mutator keyers (
typeId/mutatorId) remain module-level: they areWeakMap-backed object keyers that never strongly retain their keys, so they don't leak and are safe to share across programs.Verification
A local GC harness (using
WeakRef+--expose-gc) confirms the fix: after a mutation, dropping the program and forcing GC collects the mutation clone (WeakRef.deref()returnsundefined). Running the same harness against the pre-fix module-level cache leaves the clone pinned — reproducing the leak.Regression tests
packages/compiler/test/experimental/mutator.test.ts:reuses cached clones across top-level calls within a program— two top-level mutations of the same type/mutator within one program return the same clone (cache is shared per program).scopes the mutation cache per program— equivalent types compiled in two separate programs produce distinct clones in distinct realms; neither program sees the other's clone.breaks cycles across re-entrant mutateSubgraph calls— a self-referential model run through a mutator that re-entersmutateSubgraph(mirroring merge-patch) must not overflow the stack. Verified to fail withRangeError: Maximum call stack size exceededunder a per-engine cache, confirming it guards the regression.Validated suites: compiler experimental (21),
@typespec/httpmerge-patch (37), versioning (137), openapi3 (2547), samples (68) — all pass.