microsoft / microsoft/vscode-extension-test-runner

Source-map store never evicts decoded maps → extension host OOM crash in large workspaces

Open
#97 0 comments 0 reactions 0 assignees View on GitHub

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/vscode checkout (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 heapUsed over 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:

  1. testsInFiles keeps a maintain() handle for every test-bearing file; each handle's accessor resolves to a TraceMap.
  2. The first originalPositionFor call materializes and memoizes the full _decoded mapping array on the TraceMap.
  3. In large workspaces these maps correspond to big compiled bundles (hundreds of thousands of mappings; one decoded to 606k entries here).
  4. 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.maps cache (e.g. LRU), independent of refcount.
  • Don't retain the live TraceMap per file in testsInFiles. After computing test ranges, drop the decoded map and keep only the resolved ranges; re-decode lazily if needed.
  • Optionally avoid fully materializing _decoded for 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.