Skip to content

sourcemap: keep a mappings segment that has no source - #47734

Open
robobun wants to merge 3 commits into
mainfrom
robobun/058ecf41/sourcemap-one-field-segments
Open

robobun wants to merge 3 commits into
mainfrom
robobun/058ecf41/sourcemap-one-field-segments

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #2125

Problem

Fix

  • mapping::parse and the SIMD kernel keep a row for it, with -1 as source index, line and column.
  • find_mapping returns None there: the frame stays in the loaded file, findOrigin returns {}, coverage counts no function. findEntry returns the generated position only.
  • Unlike Node.js v26.13.0, a one-field segment that ends the string has no source either (ECMA-426, sourcemaps: don't map a trailing generated-only segment nodejs/node#66264).
  • Verified: test/js/node/module/sourcemap.test.js and three more files (24 fail on main). 10,323,998 lookups on 103 maps equal Node.js with that fix. Self-reviewed: 30 concerns raised, 28 addressed (see notes).

Background

  • mapping::List holds the decoded rows for new SourceMap(), // @bun files with a sidecar map, coverage and the dev server.
  • Considered Node's end-of-string answer: it breaks a right frame for ;AAAA;A.

Downsides

  • Main named the right file for no-source code between two tokens of one file. That frame stays in the loaded file, as in Node.js.
  • find_mapping: +5 instructions per lookup. Scalar decoder: +3 per segment (+1.7%). Binary: up to +16 KB. A 20 or 24 B row per one-field segment.
  • 32,000 one-field segments in reverse order sort in 2.4 s (main: 0.2 ms, 2.3 s with four fields).
Notes

Who reaches this on main

Lookups on the 103 maps

findEntry at the first column of each segment and at the column after it: 10,323,998 lookups for each runtime. A ; appended to mappings makes Node.js v26.13.0 read the last segment as nodejs/node#66264 does, and changes nothing else.

Node.js v26.13.0 Node.js v26.13.0, ; appended main this PR
In a one-field segment that a delimiter follows (47,454): generated position only 45,458 45,458 0 45,458
The same: an entry with a source 1,996 1,996 47,434 1,996
The same: {} 0 0 20 0
In a one-field segment that ends the string (156, in 78 maps): an entry with a source 156 0 156 0
Maps where one of the five position fields of a lookup differs from this PR 78 0 103

The 1,996 are lookups at a column where a mapped segment starts.

Differs from Node.js v26.13.0, on purpose

new SourceMap({ version: 3, sources: ["a.ts"], names: [], mappings: "AAAA,I" }).findEntry(0, 5);
// Node.js v26.13.0: { generatedLine: 0, generatedColumn: 4, originalSource: "a.ts", originalLine: 0, originalColumn: 0 }
// main:             { generatedLine: 0, generatedColumn: 0, originalSource: "a.ts", originalLine: 0, originalColumn: 0 }
// this PR:          { generatedLine: 0, generatedColumn: 4 }
  • Node's #parseMap tests isSeparator(peek()), which is false at the end of the string, so it reads the segment as a mapped one with zero deltas: https://github.com/nodejs/node/blob/v26.13.0/lib/internal/source_map/source_map.js#L290-L308
  • ECMA-426 gives the segment no source. The specification test mappingSemanticsSingleFieldSegment (AAAC,E) expects none, and it is now a test here. sourcemaps: don't map a trailing generated-only segment nodejs/node#66264 (open) changes Node.js to the same answer. Its three test cases are a test here too.
  • This is a visible change on the 78 maps that end with such a segment. There the segment is the tail of a bundle, for example })(),module.exports=i})();, and main and Node.js v26.13.0 name the last mapped source for it.
  • If a maintainer wants the answer of Node.js v26.13.0, the place for it is JSSourceMap, not the decoder. In the decoder it makes a frame of a // @bun file with the map ;AAAA;A name the original file where main names the loaded file.

Still different from Node.js, not changed here

Order against open PRs

Costs

Release builds of main and of this branch, with no ASLR, a fixed allocator seed and JSC's concurrent JIT and GC off. The rows of the Next.js map count the instructions of one new SourceMap() between two markers. The other new SourceMap() rows are instructions:u of a run with five constructions minus a run with none, divided by five. Repeated runs differ by less than 500 instructions.

main this PR
bun, stripped, linux-x64, built here 140,474,656 B 140,478,752 B (text +2,048 B)
The size check of CI, against main +0.0 KB on the eight Linux, Android and FreeBSD targets and on macOS x64, +1.5 KB on Windows x64, +6.0 KB on Windows aarch64, +16.1 KB on macOS aarch64
new SourceMap() of the Next.js map above (974,001 B of mappings) 26,552,948 26,300,300 (-0.95%)
of that, the SIMD kernel (AVX-512 target) 15,385,544 14,973,601 (-2.7%)
of that, mapping::parse (the 48,628 segments after the kernel stops at a 12-byte segment) 8,966,218 9,114,891 (+1.7%, 3 per segment)
of that, the two functions that write the 477 rows 0 10,622
new SourceMap(), 160,000 four-field segments 10,516,443 10,277,276 (-2.3%)
new SourceMap(), 160,000 four- and five-field segments 22,320,451 21,402,164 (-4.1%)
The same with 10,000 of them one-field 22,623,499 22,185,495 (-1.9%)
findOrigin, per lookup (2,000,000 lookups) 720.0 725.0
findEntry, per lookup (2,000,000 lookups) 769.0 768.0
32,000 one-field segments in reverse column order 0.2 ms 2,355 ms
32,000 four-field segments in reverse column order 2,310 ms 2,295 ms
  • The kernel does the same work for a mapped segment as on main. The difference is the registers that clang picks for the loop. With the row of a one-field segment stored inside the loop, each segment cost 2 more instructions than on main. With the store in a function that is not inlined, it costs fewer. Only the AVX-512 target was measured.
  • In mapping::parse, a second call of List::append in the loop made rustc stop inlining the first (41 more instructions per row). The row of a one-field segment is appended by a cold function that does not go through List::append.
  • The sort is the insertion sort of MultiArrayList::sort. Main reaches it with four-field segments. Node.js v26.13.0 takes 9.6 ms and 17.2 ms for the two inputs. collections: MultiArrayList stable sort is O(n log n), not insertion sort #34525 replaced the sort and was closed by the stale sweep with no review. No map of the 103 has a negative column delta.

Tests

file fail on main pass with this PR
test/js/node/module/sourcemap.test.js 18 33
test/js/node/module/sourcemap-simd.test.ts 3 27
test/js/bun/util/inspect-error.test.js 2 49
test/cli/test/coverage.test.ts 1 46

Also run with the debug build: internal-sourcemap-roundtrip, internal-sourcemap, compile-sourcemap-internal, bun-build-compile-sourcemap, external-sourcemap-stack-leak, module-sourcemap, negative-vlq-mapping, source-positions, bake/dev/sourcemap, bake/dev/server-sourcemap, inspect-inline-sourcemap, node-module-module.

Self-review

30 concerns. The ones that changed the code:

  • The first version read a one-field segment at the end of the string as Node.js v26.13.0 does. That rule is gone (see above).
  • The SIMD tests did not read the name column of a one-field row. Two tests do now, one of them pseudo-random with negative, zero and long deltas.
  • The coverage test had no mapped segment after the one-field segment, so it did not see the filter in for_each_mapping. It has one now, and its map names the loaded file as the source.

Not taken:

  • test.todo rows with the values of Node.js for the name of a four-field segment. The change that fixes the name adds them.
  • A looser lcov assertion (FNF and FNH only). The DA line is what shows that a one-field segment starts no line of its own.

no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/test/coverage.test.ts

A segment of `mappings` with one field says that the generated code
from its column on comes from no source. The two decoders dropped such
a segment. A lookup in that code then found the mapped segment before
it and named a wrong original position.

The scalar decoder and the SIMD kernel now keep a row for the segment.
The row has -1 as its source index, original line and original column.

`mapping::List::find` and `ParsedSourceMap::find_mapping` return `None`
for a position in such a row. A stack frame there keeps its position in
the file that was loaded. `SourceMap.prototype.findEntry` returns the
generated position of the segment and nothing else, as Node.js does,
and `findOrigin` returns an empty object. Code coverage does not count
a function in such code as a function of the file.

`for_each_mapping` skips the row. `write_vlqs` writes it as one field.

A one-field segment that ends the string is read as Node.js reads it:
as a mapped segment with zero deltas. In a `SourceMap` object of a map
that has no sources, that segment has no source.

`InternalSourceMap::from_vlq` still skips a one-field segment. Its
input is a map that Bun wrote, and Bun writes none.
The decoder read a one-field segment with no delimiter after it as
Node.js v26.13.0 does: as a mapped segment with zero deltas. In Node.js
that is a read past the end of the string (nodejs/node#66264). ECMA-426
and its test suite give the segment no source.

`mapping::parse` now keeps the same row for every one-field segment,
and `write_vlqs` writes no `;` after one that ends the list.

Tests:
- The rows for the end of the string have their own table.
- The three cases of the test of nodejs/node#66264 and the single-field
  case of the specification tests are added.
- A stack frame in a segment that ends the string keeps its position in
  the loaded file.
- The SIMD tests read the name of a one-field row that lies between
  segments with names.
- The coverage test has a mapped segment after the one-field segment,
  and its map names the loaded file as the source.
The row of a one-field segment was appended in the loop of each decoder.
That changed the code of the loops for every other segment:

- `mapping::parse` had two calls of `List::append`, so rustc no longer
  inlined the one of a mapped segment: 41 more instructions for each row.
- The SIMD kernel ran 2 more instructions for each segment of its
  general loop.

The scalar decoder now appends the row in `append_without_source`, a
cold function that does not go through `List::append`. The kernel
stores it in `StoreRowWithoutSource`, which is not inlined.

Measured on a release build, `new SourceMap()` of the map of
next@16.4.3 `app-page.runtime.prod.js` (162,975 segments, 477 with one
field): 26,552,948 instructions on main, 26,300,300 now. The kernel
(AVX-512 target) runs 411,943 fewer, `mapping::parse` 148,673 more
(3 for each segment that the scalar decoder reads).
@coderabbitai

coderabbitai Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 1 minute.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 271b65e and 8a2a51f.

Files selected for processing: 10
  • src/highway/lib.rs
  • src/jsc/bindings/highway_sourcemap.cpp
  • src/runtime/api/JSSourceMap.rs
  • src/sourcemap/InternalSourceMap.rs
  • src/sourcemap/Mapping.rs
  • src/sourcemap/ParsedSourceMap.rs
  • test/cli/test/coverage.test.ts
  • test/js/bun/util/inspect-error.test.js
  • test/js/node/module/sourcemap-simd.test.ts
  • test/js/node/module/sourcemap.test.js
  • 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

@github-actions github-actions Bot added the claude label Nov 2, 2026
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:15 AM PT - Nov 2nd, 2026

❌ @robobun, your commit 8a2a51f has 40 failures in Build #136853 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47734

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

bun-47734 --bun

@robobun
robobun requested a review from dylan-conway November 2, 2026 09:55
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

@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 also traced every consumer of the new -1 rows: the remaining find_mapping callers (SavedSourceMap, VirtualMachine, CodeCoverage, sourcemap/lib.rs) all go through List::find, which now filters on has_source; the one reader of the raw columns outside ParsedSourceMap (pm_diff_normalize.rs) already skips rows with a negative original line; and the SIMD kernel's new os >= os_end bail covers the extra row before any of og/oo/os/on is written.

Extended reasoning...

The change makes both the scalar and SIMD mappings decoders retain a sentinel row (source_index -1, Ordinal::INVALID original) for one-field VLQ segments and threads that through find/find_segment, for_each_mapping, the re-encoder and node:module SourceMap.findEntry; it touches no auth, crypto or injection surface. The one confirmed inline finding (sort cost on reverse-ordered one-field segments) is the only open issue; the sentinel-consumer audit found no reader that treats -1 as an index.

Comment thread src/sourcemap/Mapping.rs Resolved

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