Skip to content

sourcemap: pass the real source count to the mappings decoder - #47240

Open
robobun wants to merge 3 commits into
mainfrom
robobun/fba4fb53/source-index-bound
Open

robobun wants to merge 3 commits into
mainfrom
robobun/fba4fb53/source-index-bound

Conversation

@robobun

@robobun robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • mapping::parse decodes mappings. It refuses a map whose source index reaches its caller's count. For the map of a built file, parse_json passes i32::MAX (src/sourcemap/lib.rs:954).
  • A mapping past sources prints the built file name with the original line: entry.js:5:3.
  • The dev server passes one too few (src/runtime/bake/dev_server/source_map_store.rs:677). Since Fix source index bounds check in sourcemap decoder #24145, /_bun/report_error remaps nothing when a bundle's last file has mappings.

Fix

Background

  • A built file starts with // @bun. Bun reads its map on the first stack trace.
  • The dev server's map has its HMR runtime (its hot-update client script) as source 0.
  • Considered un-mapping only the bad frame, as node does: a branch per lookup. The count argument costs none.
  • sourcemap: accept a source map whose sourcesContent is absent or null #36582 accepts more maps. Land this before or with it.

Downsides

  • One mapping past sources now un-maps its whole file, with one warning. Main and node remap the rest. 0 of 542 bun-built maps from npm have one.
  • parse_json grows 51 bytes, get_source_code 23 (crate codegen, no ThinLTO). No linked release binary was measured.
Notes

Repro for the source index. entry.js:

// @bun
function t() {
  throw new Error("SI");
}
try { t(); } catch (e) { console.log(String(e.stack).split("\n")[1].trim()); }
//# sourceMappingURL=entry.js.map

entry.js.map (one source, and a mapping to source index 1):

{"version":3,"sources":["original.ts"],"sourcesContent":[null],"names":[],"mappings":";;ECIE;"}
  • main: at t (entry.js:5:3), no warning. Line 5 and column 3 are the original position, entry.js is the built file.
  • this PR: at t (entry.js:3:18) and warn: Could not decode sourcemap in 'entry.js': InvalidSourceIndexValue.
  • node 26.3 with --enable-source-maps: at t (entry.js:3:9).

A map with "sources":[] and a 4-field mapping prints the same mixed frame on main. Here it is refused too.

Why the whole map. The decoder already refuses the whole map for a negative source index, a negative line or column, and a bad VLQ. new module.SourceMap and bun pm diff pass their real count to the same decoder. parse_json was the one caller that turned the check off. Node decides for each frame: it leaves a frame with an unknown source unmapped and remaps the others. In a probe of 10 maps with 2 frames each, this PR leaves 1 frame at its built position that main remaps (the in-range frame of the map above, in a second function).

Probe of 10 maps, release build of main (11c3671) against this PR.

  • maps with a mixed frame: main 2, this PR 0
  • maps with a warning: main 7, this PR 9 (main refuses the 7 for sourcesContent or sources, and this PR does not change that)

The dev server. Its map has the HMR runtime as source 0 and file i of the bundle as source i + 1 (join_vlq, source_map_store.rs:278). get_parsed_source_map passed paths.len(). #24145 changed the decoder's check from > to >=. From then on the index of the last file was out of range and the parse failed. The reader of the result already expects file_paths[index - 1] (error_report_request.rs:242).

  • A frame in each of two files of the first bundle: main remaps 0 of 2, this PR 2 of 2.
  • A frame in a hot update chunk (hmr-<id>.js): main leaves it, this PR remaps it.
  • On Windows the reply keeps an absolute path, because only a path that starts with / is made relative. The test compares base names. It was not run on Windows here.

Maps that the new bound refuses. Decoded with new module.SourceMap, which uses the same decoder with sources.length. A map counts only if it decodes once sources is padded, so a negative index (refused on main already) is not counted.

  • 0 of 542 maps that bun built and that 71 npm packages ship (2 of them have a negative source index and are refused on main).
  • 0 of 192 maps from bun build: 102 packages of test/node_modules bundled with --target=bun, external and inline, up to 2,113 sources in one map.
  • 2 of 8,111 .map files in the checkout. Both are invalid-map fixtures of the tc39 suite (invalid-mapping-segment-source-index-out-of-bounds, invalid-mapping-bad-separator).
  • The SIMD decoder and the scalar decoder agree on every map.

Cost.

  • Calls for a valid map (parse_json / mapping::parse / mapping::List::find, gdb hit counts on the debug build of this PR): load only 0/0/0, one caught stack 1/1/2, 1000 caught stacks 1/1/2000, an uncaught error 2/1/2, 1000 stacks in a transpiled .ts 0/0/0. A release build of main gives the same counts for the first two functions (List::find is inlined there). The diff adds no call, no allocation and no file read.
  • Release codegen of the bun_sourcemap crate (opt-level 3, machine code from the crate's bitcode with clang -O3 -march=nehalem, no ThinLTO): parse_json 2413 to 2464 bytes and 498 to 504 instructions. Lookup::get_source_code 789 to 812 bytes: the deleted guard was also what let the compiler drop the bounds check of the index. mapping::parse (5393 bytes) and List::find (412) do not change. The crate's .text goes from 53,569 to 53,633 bytes.
  • Not measured: a linked release binary, instructions for each call, and bun_runtime (the dev server change is one + 1 for each error report). A cold debug build takes about 2 hours on the test machine. perf, valgrind, strace and bloaty are not installed there.

Suites on the debug build of this PR. test/js/bun/sourcemap/ (370), test/js/node/module/sourcemap.test.js (14), test/js/node/module/sourcemap-simd.test.ts (24), test/js/bun/util/inspect-error.test.js (46), test/cli/test/coverage.test.ts (43), test/regression/issue/22003.test.ts (1), test/bundler/bundler_edgecase.test.ts -t EmitInvalidSourceMap (4), and in test/bake/dev: html (17), sourcemap (2), server-sourcemap (5), production (21), esm (18), bundle (54), hot (12), css (18).

Overlap.

Left for later.

  • The last //# sourceMappingURL= line anywhere in a built file is read as its inline map, also when it is text inside a string. See sourcemap: ignore a sourceMappingURL line inside a string or a block comment #47347.
  • A 1-field segment does not un-map its range (node does). Another change owns this.
  • new module.SourceMap throws for a source index past sources. Node returns an entry without originalSource.
  • InternalSourceMap::from_vlq takes no source count. It reads only maps that the bundler made.

mapping::parse refuses a map when a segment names a source index that
is not below the count its caller passes. Two of its four callers
passed a wrong count.

parse_json passed i32::MAX, which turned the check off. A mapping that
names a source past `sources` stayed in the map, and its stack frame
got the built file name with the original line and column. It now
passes sources.len(). Such a map is refused with
InvalidSourceIndexValue, as a map with a negative source index is.

The dev server passed paths.len(). Source 0 of its map is the HMR
runtime and file i is source i + 1, so the decoder refused the map when
the last file of a bundle had mappings, and /_bun/report_error remapped
no frame. It now passes paths.len() + 1.

The range guard in Lookup::get_source_code cannot fire with the bound
in place, so it is deleted.
@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 6c7b52f and 017ef12.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 34cbc70 and 6c7b52f.

Files selected for processing: 5
  • src/runtime/bake/dev_server/source_map_store.rs
  • src/sourcemap/Mapping.rs
  • src/sourcemap/lib.rs
  • test/bake/dev/html.test.ts
  • test/js/bun/sourcemap/negative-vlq-mapping.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

The source-map parser now receives source counts derived from available paths. Tests cover invalid source indexes and frame remapping for client bundles and HMR chunks.

Changes

Source-map parsing and frame reporting

Layer / File(s) Summary
Source-index parsing and validation
src/sourcemap/Mapping.rs, src/sourcemap/lib.rs, test/js/bun/sourcemap/negative-vlq-mapping.test.ts
mapping::parse receives the source-path count. Lookup no longer checks the source index before indexing external_source_names. Tests cover valid and invalid source indexes in scalar and SIMD decoding, plus external-sourcemap bundle output.
HMR source counts and reported frames
src/runtime/bake/dev_server/source_map_store.rs, test/bake/dev/html.test.ts
The bake source-map parser includes the HMR runtime in its source count. Tests report client-bundle and HMR-chunk frames and check returned file positions.

Suggested reviewers: alii, jarred-sumner

Merge Risk: Minimal · previous review 6c7b5

No actionable merge-blocking issue remains. Source-index validation and HMR source counts are consistent with their consumers; the change is mergeable subject to normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: passing the real source count to the mappings decoder.
Description check Passed The description is detailed and covers the change, verification steps, test results, tradeoffs, and follow-up work. It does not use the exact template headings, but it provides the required informatio…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and covers the change, verification steps, test results, tradeoffs, and follow-up work. It does not use the exact template headings, but it provides the required information under equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:07 AM PT - Nov 1st, 2026

❌ @robobun, your commit 017ef12 has 42 failures in Build #136299 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47240

That installs a local version of the PR into your bun-47240 executable, so you can run:

bun-47240 --bun

@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on a release build of main (11c3671).

  • Source index: a // @bun file whose map has one source and a mapping to source index 1 prints at t (entry.js:5:3). That is the built file name with the original position. With this PR the map is refused with InvalidSourceIndexValue, and the frame stays at entry.js:3:18.
  • Dev server: /_bun/report_error remaps 0 of 2 frames of a two-file page on main, and 2 of 2 with this PR. A frame in a hot update chunk is remapped too.
  • Tests: 4 of the 5 new tests fail on main. All pass on the debug build of this PR.
  • CI: build 136299 (at 017ef12, merged with main d70e177) is red for reasons outside this diff. 41 of its 42 red test files are AOT tests (test/aot/ and test/bundler/compile-*). At least 30 of them are also red on builds 136296 and 136333 of main. The other one is test/js/web/request/request-clone-leak.test.ts on the ASAN lane (RSS delta 64 MB against a limit of 64). 12 of the 16 macOS x64 test jobs expired before an agent took them. binary-size passes. No source map test and no dev server test failed.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I checked the deleted range guard in Lookup::get_source_code: external_source_names is only ever filled by parse_json (same length as the sources_count it now passes) and by the standalone graph (which always takes the branch that keeps its own bound check), the sole caller is gated on is_external(), both decoders refuse the whole parse on an out-of-range index (the Highway kernel bails before committing the row), so the indexing cannot go out of bounds. The dev-server paths.len() + 1 is exact: paths/files are pushed in lockstep and the reader already uses file_paths[index - 1] behind its own bound check.

Extended reasoning...

The change is three one-line edits in the sourcemap decoder's callers plus one deleted guard, with two new test files covering both decoders, at-limit/one-past boundaries, inline and external maps, and the dev-server error-report endpoint. It touches no auth, crypto, or injection surface; the untrusted input is a sourcemap's VLQ string, and the new bound tightens validation. Deferring rather than approving because the whole-map refusal (versus per-frame un-mapping as node does) is a user-visible behavior tradeoff the inline finding records, and a maintainer should weigh it.

Comment thread src/sourcemap/lib.rs
mappings_vlq,
None,
i32::MAX,
i32::try_from(sources_paths.items().len()).unwrap_or(i32::MAX),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users running a prebuilt // @ bun file whose map has one segment past sources now lose remapping for every frame in that file, with a warn: line, where main remapped the in-range frames. src/sourcemap/lib.rs:954 passes sources.len() to mapping::parse, and Mapping.rs:582 refuses the whole map on the first out-of-range index, so get_source_map_impl returns None at lib.rs:552 and SavedSourceMap.rs:329 drops the entry for good. Node leaves only the bad frame unmapped. Fix: match node per frame: let parse keep the map and have Lookup::display_source_url_if_needed/get_source_code return None for an out-of-range source_index (the guard this PR deletes at Mapping.rs:386), or at least keep accepting sources: [] maps.

Why this was flagged

Trigger: any JSON map reachable through parse_json (lib.rs:447 inline, :468 dev-server provider, :493 registered, :538 <file>.map on disk) in which at least one 4- or 5-field segment names an index >= sources.length, or whose sources is [] while mappings carries 4-field segments. On the base commit parse_json passed i32::MAX (lib.rs:954 old), the map parsed, in-range frames were remapped to their original file and only the bad frame printed a mixed position. After this change Mapping.rs:582 returns InvalidSourceIndexValue for the first bad segment, lib.rs:545-552 warns Could not decode sourcemap in '<file>': InvalidSourceIndexValue and returns None, and SavedSourceMap.rs:329-338 removes the entry so the file is never retried: every stack trace, Bun.inspect(error) preview, and bun test --coverage lookup for that file stays at built positions for the life of the process. A per-frame guard (the one deleted at Mapping.rs:386, plus the kept one at Mapping.rs:321) would reject only the bad frame as node does.

Verification: src/sourcemap/lib.rs:954 now passes i32::try_from(sources_paths.items().len()).unwrap_or(i32::MAX) where the base passed i32::MAX. src/sourcemap/Mapping.rs:582-589 returns Err(ParseFail { err: InvalidSourceIndexValue }) on the first out-of-range index, and src/jsc/SavedSourceMap.rs:327-333 removes the table entry, so no frame in that file is remapped for the rest of the process.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. The guard you suggest is what main does today, and it is the cause of the mixed frame. display_source_url_if_needed returns None for an index past the names (Mapping.rs:321). The three remap sites in VirtualMachine.rs then keep the built URL and still copy the original line and column. That prints entry.js:5:3.

A verdict for each frame needs more than that guard. Every reader of a lookup has to treat the mapping as absent: the three remap sites, coverage, and the content pick in parse_json. Or the decoder has to mark the row. Both add a branch to each lookup, for every map.

The whole-map refusal is what this decoder already does for a negative source index, a negative line or column, and a bad VLQ. new module.SourceMap passes its real count to the same decoder and throws.

Reach: 0 of 542 bun-built maps from npm packages have such a row, and 0 of 192 maps from bun build. 2 of 8,111 .map files in the checkout have one. Both are invalid-map fixtures of the tc39 suite.

For sources: [] with 4-field mappings, every frame of that file is a mixed frame on main. Node leaves those frames unmapped. With this PR they are unmapped too, with one warning.

The cost is the first Downsides bullet: the in-range frames of such a map are no longer remapped. I leave this thread open so that a maintainer can decide.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants