microsoft / microsoft/vscode-extension-test-runner
Source-map store never evicts decoded maps → extension host OOM crash in large workspaces
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 17
- Forks
- 15
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 2
Description
Summary
In a large workspace, the Extension Test Runner accumulates fully-decoded source maps in a module-level cache that is never evicted. Over a session (hours) the extension host's JS heap climbs to V8's old-space limit and the extension host crashes with a fatal abort() (V8 "JavaScript heap out of memory"). VS Code then reports "Extension host terminated unexpectedly" and auto-restarts it.
I traced this from an extension host crash dump → live heap snapshot → the leaking cache in out/extension.js.
Environment
- Extension Test Runner: 0.0.14
- VS Code: 1.127.0-insider (commit
2fb1c411a7933676c4efd18140f2a98fc910c945) - Node in extension host: v24.15.0
- OS: macOS 26.5.1 (arm64)
- Workspace:
microsoft/vscodecheckout (very large; many compiled bundles with large source maps)
Symptoms
main.log:[UtilityProcess … type: extensionHost …]: crashed with code 6 and reason 'crashed'- Renderer:
Extension host (LocalProcess pid: …) terminated unexpectedly … Automatically restarting the extension host. - Crash minidump: crashing thread is
abort()deep in the V8 runtime/GC (classic V8 fatal OOM), with hundreds of idle V8 GC-helper threads — the OOM death spiral. - Live extension hosts grow to 650MB–900MB
heapUsedover tens of minutes to hours before crashing.
Root cause
Heap-snapshot retainer analysis of the extension host shows the dominant retained memory is decoded source maps held by this extension. Retainer chain:
Map (module singleton, keyed by file:///…/<workspace>/…) ← never-evicting cache (Nt.maps)
└─ cache entry { rc, accessor: Promise }
└─ { originalPositionFor } (@jridgewell/trace-mapping)
└─ TraceMap._decoded = Array of ~600k line-arrays ← one map alone ≈ 185MB+
~72–82 TraceMap instances were retained, one with 606,653 decoded line entries.
The relevant code (minified out/extension.js, class Nt = source-map store, class Rt = per-folder controller):
// source-map store
var Nt = class {
constructor(){ this.maps = new Map }
maintain(e){
let t=this.maps, s=e.toString(), r=t.get(s);
return r || (r={rc:0}, t.set(s,r)), r.rc++, {
compiledUri:e,
get value(){ return r.accessor },
async refresh(i){
let o = readFile(e.fsPath,"utf8") || Promise.resolve(i);
return r.accessor = o.then(c => new TraceMap(…), () => fallback(e));
},
dispose(){ --r.rc===0 && t.delete(s) } // only freed when refcount hits 0
}
}
};
// consumer keeps the handle resident per file:
let o = prev?.sourceMap ?? this.smStore.maintain(uri);
let c = await o.refresh(contents);
this.testsInFiles.set(uri.toString(), { items, hash, sourceMap:o }); // retains decoded map per file
… c.originalPositionFor(line, col) … // forces & memoizes full _decoded
Why it grows unbounded / blows up memory:
testsInFileskeeps amaintain()handle for every test-bearing file; each handle'saccessorresolves to aTraceMap.- The first
originalPositionForcall materializes and memoizes the full_decodedmapping array on theTraceMap. - In large workspaces these maps correspond to big compiled bundles (hundreds of thousands of mappings; one decoded to 606k entries here).
- They stay resident for all tracked files and accumulate across discovery/extraction. Entries are only freed when refcount reaches 0 (
dispose), which doesn't happen while files remain tracked — so the heap keeps climbing until the ext host OOM-crashes.
Suggested fix
- Cap/evict the
Nt.mapscache (e.g. LRU), independent of refcount. - Don't retain the live
TraceMapper file intestsInFiles. After computing test ranges, drop the decoded map and keep only the resolved ranges; re-decode lazily if needed. - Optionally avoid fully materializing
_decodedfor very large source maps (query without forcing the whole decode, or release the memoized decode after extraction).
Impact
Any sufficiently large workspace using the test runner will eventually crash its extension host (and any other extensions running in it) after the host's heap fills. Reproduces reliably "after a while" rather than on demand.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the source-map store class Nt and per-folder controller Rt in out/extension.js, then trace how testsInFiles retains maintain() handles and how TraceMap.originalPositionFor materializes decoded data. Reproduce in a large workspace while observing extension-host heap usage. Done means tracked files no longer retain decoded maps indefinitely and the extension host remains stable during extended test discovery.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100