Skip to content

fix(live-debugger): emit correct sourcemaps for instrumented functions#337

Draft
watson wants to merge 1 commit intowatson/DEBUG-5465/do-not-treat-args-as-localsfrom
watson/DEBUG-5544/fix-source-maps
Draft

fix(live-debugger): emit correct sourcemaps for instrumented functions#337
watson wants to merge 1 commit intowatson/DEBUG-5465/do-not-treat-args-as-localsfrom
watson/DEBUG-5544/fix-source-maps

Conversation

@watson
Copy link
Copy Markdown
Contributor

@watson watson commented May 4, 2026

What and why?

Make the Live Debugger transform produce correct source maps for the code it injects.

When a Live Debugger probe is hit at runtime, the captured stack frame is reported back to the backend with positions in the bundled JavaScript. The deobfuscation service uses the source maps emitted by this build pipeline to translate those positions into the file path, line number and column the user sees in the UI. For those user-facing positions to actually point at the function the probe wraps, the source map for each transformed file has to satisfy two properties:

  1. Carry source-map segments for the purely-injected probe code. The transform inserts a preamble (probe declaration, try opener, entry call) and a postamble (return helper, catch block) into the function body — those lines exist only in the bundled output, not in the original source. If they have no segments, captured stack frames hitting them resolve to null.

  2. Report positions in the original source, not in the intermediate buffer the transform was handed. With enforce: 'post' the transform sees the post-loader JavaScript (e.g. post-SWC, with TypeScript type imports stripped). Magic-string therefore anchors injected lines to positions in that buffer while labelling them with the original file path. Without composing back to the original source, downstream consumers report the post-loader line numbers as if they were original-source line numbers — captured probe stack frames in production were resolving to import statements near the top of the file rather than the function body.

After this PR, captured probe stack frames resolve to the exact original line and column of the function being instrumented, regardless of where in the preamble/postamble they land and regardless of how heavily upstream loaders shifted line numbers.

How?

Three pieces, all in packages/plugins/live-debugger/src/:

1. Wrap the function body's boundary characters via MagicString#update rather than appendLeft. For each instrumented function we edit:

  • the opening { (or, when present, the trailing terminator of the last leading directive) with <char><preamble>,
  • the closing } with <postamble><char>.

update() flows through Mappings.addEdit, which emits one source-map segment per line of the new content, all anchored at the original chunk being edited. So every line of the injected preamble and postamble has a mapping that points back to the function's own source location. appendLeft content goes through Mappings.advance and produces no segments at all.

Two corner cases:

  • For a single-character expression body (e.g. () => 1), the two boundary ranges collide, so we fall back to a single update() covering the whole body.
  • For directives, the preamble is preceded by a newline so the directive's own terminator stays on the directive's line.

update's default overwrite: false preserves any intro/outro that surrounding appendLeft calls placed on neighbouring chunks, so the appendLeft-based wrapping of return statements composes correctly with the boundary updates.

2. hires: true on s.generateMap(), so every original character emits its own source-map segment. Without it, mappings are emitted at line boundaries only and column information for the function signature, parameters, return body, etc. is lost in the round-trip.

3. Compose the magic-string delta map with the input source map produced by the previous loader, using @jridgewell/remapping (already in the lockfile via unplugin). The input map is exposed by unplugin's native build context (getNativeBuildContext().inputSourceMap) on webpack/rspack; rollup/vite/esbuild compose maps through their own pipelines and don't need this. When remapping throws (malformed input map, very rare), we fall back to the un-composed map and log at error level — matching the Instrumentation Error precedent in the same handler — so a single bad upstream map doesn't kill instrumentation for every other file in the build.

Test plan

  • Unit tests in src/index.test.ts cover the four handler code paths: compose-with-input-map, no-input-map passthrough, no-instrumentation early-return, and the error-with-fallback path (which asserts the composition failure is logged at error level with forward: true).
  • Transform-level test in src/transform/index.test.ts asserts the magic-string segments resolve back to the function's declaration line, regardless of where the entry-call lands in the generated output.
  • Integration test in src/sourcemap.integration.test.ts exercises the full rspack pipeline with two shim loaders:
    • identity-shim loader: validates basic line + column accuracy on original characters.
    • banner-shift loader: prepends 4 lines to the source and emits a non-identity input map. This is the regression test for the composition path — without composition, captured positions resolve to line 5+ of the post-loader buffer; with composition, they resolve to line 1 of the original source. An identity-shim is indistinguishable from no composition, so this case wouldn't catch a regression on its own.

Copy link
Copy Markdown
Contributor Author

watson commented May 4, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

Live Debugger probes capture stack traces from instrumented functions.
For those traces to point at the right place in the user's source, the
source map for each transformed file must (a) carry segments for the
purely-injected probe code and (b) report positions in the original
source rather than in whatever intermediate buffer the transform was
handed.

For (a), wrap each function body's boundary characters (the opening
`{`, the closing `}`, and the trailing terminator of any leading
directive) via `MagicString#update` rather than `appendLeft`. `update`
routes content through `Mappings.addEdit`, which emits one source-map
segment per line of the new content anchored at the boundary
character's original location; `appendLeft` content goes through
`Mappings.advance` and produces no segments at all. Combined with
`hires: true`, every injected line resolves back to the function it
wraps, and the preamble and postamble can stay multi-line and readable
in the bundled output.

For (b), compose the magic-string delta map with the input source map
produced by the previous loader (e.g. `builtin:swc-loader` stripping
TypeScript types). With `enforce: 'post'` the transform sees the
post-loader buffer, so magic-string anchors to positions in *that*
buffer and labels them with the original file path. Without
composition the bundler reports those post-loader positions as if they
were original-source positions — captured probe stack frames in
production resolved to import statements near the top of the file
rather than the function body. Compose via `@jridgewell/remapping`
(already in the lockfile via `unplugin`) using the `inputSourceMap`
exposed by unplugin's native build context on webpack/rspack;
rollup/vite/esbuild compose maps through their own pipelines and don't
need this. Fall back to the un-composed map and log at error level —
matching the `Instrumentation Error` precedent in the same handler —
when composition throws, so a single malformed input map doesn't kill
instrumentation for every other file.

The integration test that exercises the full rspack pipeline uses a
non-identity input map (a banner-shift loader that shifts source line
numbers) so composition is validated end-to-end; an identity shim is
indistinguishable from no composition.
@watson watson force-pushed the watson/DEBUG-5544/fix-source-maps branch from c59aefd to cbb54ad Compare May 5, 2026 05:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant