microsoft / microsoft/vscode-js-debug

Sourcemap resolution fails when multiple V8 execution contexts load the same script URL (race condition in addSource)

Open
#2,342 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
TypeScript
Stars
2k
Forks
373
Avg merge
1d 9m
Merged PRs (30d)
6

Description

Environment

  • js-debug version: nightly 2026.4.317 (also checked latest main branch source — same code)
  • VS Code version: latest
  • Debug type: pwa-node (attach mode, CDP)
  • OS: Windows 11

Description

When debugging an application that creates multiple V8 execution contexts (e.g., a Chromium Embedded Framework app with ~50 contexts), source map resolution silently fails for scripts that are loaded in more than one context with the same URL.

The debugger shows the compiled source instead of the original TypeScript source, and breakpoints set in .ts files are not hit.

Root Cause Analysis

The issue is a race condition in SourceContainer.addSource() (src/adapter/sourceContainer.ts).

Timeline
  1. scriptParsed event #1 arrives for shared.js (scriptId 224, executionContextId 47).
    _onScriptParsed checks getSourceByOriginalUrl("shared.js")undefined → proceeds to call addSource().

  2. scriptParsed event #2 arrives for shared.js (scriptId 241, executionContextId 48).
    _onScriptParsed checks getSourceByOriginalUrl("shared.js")still undefined (Source1 not registered yet) → also proceeds to call addSource().

  3. Inside addSource(), await this.sourcePathResolver.urlToAbsolutePath(...) yields execution. Both calls are now in-flight concurrently.

  4. Call #1 resumes: creates Source1, calls _addSource(Source1) → registers Source1 in _sourceByOriginalUrl and _sourceByReference. Starts async _finishAddSourceWithSourceMap(Source1).

  5. Call #2 resumes: creates Source2, calls _addSource(Source2).
    → In _addSource, the existing-URL check finds Source1 → removes Source1 (calls removeSource) → registers Source2.

  6. _finishAddSourceWithSourceMap(Source1) resumes after loading the .map file. It checks:

    if (this._sourceByReference.get(source.sourceReference) !== source) {
      return deferred.resolve(undefined); // ← Source1 was evicted!
    }
    

    Source1 has been evicted → sourceMap resolves to undefined.

  7. Result: scriptId 224 is permanently linked to Source1 (which has no sourceMap). When the debugger pauses in scriptId 224, _originalPositionFor() returns UnmappedReason.HasNoMap → no source mapping occurs.

Evidence from DAP trace log
  • Two loadedSource events for the same file, both with "reason": "new" (dedup failed):
    {"seq":0,"type":"event","event":"loadedSource","body":{"reason":"new","source":{"name":"shared.js","path":"...\\shared.js","sourceReference":1936498801}}}
    {"seq":0,"type":"event","event":"loadedSource","body":{"reason":"new","source":{"name":"shared.js","path":"...\\shared.js","sourceReference":1936498801}}}
    
  • stackTrace response shows compiled positions only — no sourcemap resolution despite a valid .map file being loaded.
Existing TODO in code

There's already a TODO comment in _addSource acknowledging this scenario:

// todo: we should allow the same source at multiple uri's if their scripts
// have different executionContextId. We only really need the overwrite
// behavior in Node for tools that transpile sources inline.

Suggested Fix

Add a deduplication check in addSource() after the await, before creating a new Source:

public async addSource(
  event: Cdp.Debugger.ScriptParsedEvent,
  contentGetter: ContentGetter,
  sourceMapUrl?: string,
  inlineSourceRange?: InlineScriptOffset,
  runtimeScriptOffset?: InlineScriptOffset,
  contentHash?: string,
): Promise<Source> {
  const absolutePath = await this.sourcePathResolver.urlToAbsolutePath({ url: event.url });

  // --- FIX: reuse existing source if same URL + same content ---
  if (contentHash) {
    const existing = this._sourceByOriginalUrl.get(event.url);
    if (existing && existing.contentHash === contentHash) {
      return existing;
    }
  }
  // --- END FIX ---

  // ... rest of the method

This ensures that when two scriptParsed events for the same script (same URL, same content hash) race through addSource(), the second call reuses the already-registered Source instead of creating a duplicate that evicts the first.

Reproduction

Any application that:

  1. Creates multiple V8 execution contexts (e.g., web workers, iframes, or embedded V8 environments)
  2. Loads the same script URL in multiple contexts
  3. Uses source maps

Will trigger this bug. The more contexts that load the same script, the higher the probability of hitting the race window.

Contributor guide

Open the contributing guide

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 src/adapter/sourceContainer.ts, especially addSource(), _addSource(), and _finishAddSourceWithSourceMap(). Trace the concurrent scriptParsed flow around the sourcePathResolver await, then reproduce with multiple V8 contexts loading the same URL and a source map. Done means duplicate loading no longer evicts the source whose map is still being resolved, and original TypeScript positions remain available.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.