Skip to content

Build the coverage line table in UTF-16 code units - #38710

Open
robobun wants to merge 2 commits into
farm/612517fd/16bit-sourcesfrom
farm/612517fd/coverage-utf16
Open

Build the coverage line table in UTF-16 code units#38710
robobun wants to merge 2 commits into
farm/612517fd/16bit-sourcesfrom
farm/612517fd/coverage-utf16

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Second of the four PR stack split out of #33866, based on #38708 (1: #38708, 2: this, 3: disk sources decoded as UTF-8, 4: #33866). The diff against #38708 is src/sourcemap_jsc/CodeCoverage.rs and test/cli/test/coverage.test.ts, plus a one-line comment update in src/runtime/cli/test_command.rs that referred to the removed field; nothing here depends on #38708, it is only stacked for ordering.

Problem

  • bun test --coverage misattributes lines in any file whose module text contains non-ASCII characters. Today a non-ASCII preserved comment (/*! © ... */, the usual license banner) is enough: in the new test's fixture every line after the banner is shifted, the two functions that never run disappear from the lcov record, and unrelated lines are reported as unexecuted (full diff in the test output on main: DA:7,0 / DA:10,0 become DA:4,0 / DA:5,0 / DA:6,0).
  • Cause: JSC reports block ranges as offsets in code units of the source string it holds. ByteRangeMapping (src/sourcemap_jsc/CodeCoverage.rs) receives the UTF-8 form of that string (ByteRangeMapping__generate calls to_utf8()), builds a table of byte offsets of line starts from it, and looks the code unit offsets up in that table. The two agree only while the text is ASCII.

Fix

  • The mapping stores the start of each line in code units: the byte offsets are used as-is for ASCII text, otherwise each line is re-measured with element_length_utf8_into_utf16. This is correct for both kinds of string JSC can hold (an 8-bit string with characters above 0x7F, which is what the runtime produces for such a comment today, and a 16-bit string), because the code unit count of the UTF-8 re-encoding of either equals the offsets JSC uses.
  • The line offset table's per-line column tables were never used here; they are now freed right after the line starts are taken instead of living as long as the mapping.
  • The byte_offset locals in the report loops and the ByteRangeMapping name itself follow JSC's own "byte range" terminology for these offsets and are left as they are; the unit is documented on the field they are compared against.
  • Verification: test/cli/test/coverage.test.ts "a non-ASCII preserved comment does not shift coverage lines" runs the same module twice, once with an ASCII banner and once with a ©🐰 banner of the same length in code units (astral characters make code units differ from both bytes and code points), and requires identical lcov records, after checking the ASCII one reports the two unexecuted functions on lines 7 and 10. Fails on main as described above; passes here along with the rest of the file. The helper is shaped so the regex / tagged template variant, which needs Preserve non-ASCII in regex .source and tagged template .raw #33866, can be added there as a second test.

Background

  • lcov DA:<line>,<count> records are what --coverage-reporter=lcov writes per line; comparing records of two files that differ only in text is a direct check of line attribution.
  • JSC strings are 8-bit (one Latin-1 character per byte) or 16-bit (UTF-16 code units); offsets it reports are indices into whichever it holds. to_utf8() re-encodes either into UTF-8, which is longer than the string's unit count as soon as there is a character above 0x7F.

[review] gate passed · iteration 10 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/test/coverage.test.ts
bun test v1.4.0 (785825f22)

test/cli/test/coverage.test.ts:
bun test v1.4.0 (785825f22)

demo.test.ts:
--------------|---------|---------|-------------------
File          | % Funcs | % Lines | Uncovered Line #s
--------------|---------|---------|-------------------
All files     |    0.00 |   66.67 |
 demo.test.ts |    0.00 |   66.67 | 
--------------|---------|---------|-------------------

 0 pass
 0 fail
Ran 0 tests across 1 file. [354.00ms]
(pass) coverage crash [473.19ms]
bun test v1.4.0 (785825f22)

demo2.ts:

 0 pass
 0 fail
Ran 0 tests across 1 file. [335.00ms]
(pass) lcov coverage reporter [400.38ms]
(pass) coverage excludes node_modules directory [308.80ms]
(pass) coveragePathIgnorePatterns - single pattern string [456.33ms]
(pass) coveragePathIgnorePatterns - partial coverage without nan [558.11ms]
(pass) coveragePathIgnorePatterns - array of patterns [433.28ms]
(pass) coveragePathIgnorePatterns - glob patterns [501.25ms]
(pass) coveragePathIgnorePatterns - lcov reporter [411.49ms]
(pass) cov
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (b7a043103)

test/cli/test/coverage.test.ts:
bun test v1.4.0-canary.1 (b7a043103)

demo.test.ts:

 0 pass
 0 fail
Ran 0 tests across 1 file. [11.00ms]
(pass) coverage crash [19.07ms]
bun test v1.4.0-canary.1 (b7a043103)

demo2.ts:

 0 pass
 0 fail
Ran 0 tests across 1 file. [9.00ms]
(pass) lcov coverage reporter [17.26ms]
(pass) coverage excludes node_modules directory [26.13ms]
(pass) coveragePathIgnorePatterns - single pattern string [24.71ms]
(pass) coveragePathIgnorePatterns - partial coverage without nan [28.56ms]
(pass) coveragePathIgnorePatterns - array of patterns [31.89ms]
(pass) coveragePathIgnorePatterns - glob patterns [30.46ms]
(pass) coveragePathIgnorePatterns - lcov reporter [20.99ms]
(pass) coveragePathIgnorePatterns - invalid config type [6.85ms]
(pass) coveragePathIgnorePatterns - invalid array item [4.80ms]
(pass) coveragePathIgnorePatterns - empty array [22.77ms]
(pass) coveragePathIgnorePatterns - ignore all files [19.11ms]
669 |     legalComment: coverageDemo(repeatUnits("©🐰", coverageBannerUnits), asciiText),
670 |   });
671 |   // `uncovered` and `alsoUncovered` start on lines 7 and 10.
672 |   expect(ascii).toCon
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/test/coverage.test.ts
bun test v1.4.0 (785825f22)

test/cli/test/coverage.test.ts:
bun test v1.4.0 (785825f22)

demo.test.ts:
--------------|---------|---------|-------------------
File          | % Funcs | % Lines | Uncovered Line #s
--------------|---------|---------|-------------------
All files     |    0.00 |   66.67 |
 demo.test.ts |    0.00 |   66.67 | 
--------------|---------|---------|-------------------

 0 pass
 0 fail
Ran 0 tests across 1 file. [232.00ms]
(pass) coverage crash [316.70ms]
bun test v1.4.0 (785825f22)

demo2.ts:

 0 pass
 0 fail
Ran 0 tests across 1 file. [238.00ms]
(pass) lcov coverage reporter [310.09ms]
(pass) coverage excludes node_modules directory [296.82ms]
(pass) coveragePathIgnorePatterns - single pattern string [329.32ms]
(pass) coveragePathIgnorePatterns - partial coverage without nan [311.27ms]
(pass) coveragePathIgnorePatterns - array of patterns [317.27ms]
(pass) coveragePathIgnorePatterns - glob patterns [323.85ms]
(pass) coveragePathIgnorePatterns - lcov reporter [319.39ms]
(pass) cov
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 946ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] gen bindgenv2
[3/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[4/1238] fetch tinycc
[tinycc] up to date
[5/1237] gen .bind.ts → GeneratedBindings.cpp
[6/1237] fetch zlib
[zlib] up to date
[7/1237] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[8/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[9/1237] install /workspace/bun
bun install v1.4.0-canary.1 (b7a043103)

Checked 107 installs across 153 packages (no changes) [304.00ms]
[10/1237] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [7.00ms]
[11/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [19.00ms]
[12/1237] gen 
... (truncated)
diff hotspot
src/runtime/cli/test_command.rs   |  4 +-
 src/sourcemap_jsc/CodeCoverage.rs | 42 ++++++++++++++++----
 test/cli/test/coverage.test.ts    | 84 +++++++++++++++++++++++++++++++++++++++
 3 files changed, 121 insertions(+), 9 deletions(-)

gate history · 1 passed · 0 rejected · iteration 10

evidence per changed file
file                               reads  edits  tests
src/runtime/cli/test_command.rs        0      0      0
src/sourcemap_jsc/CodeCoverage.rs      3      3      0
test/cli/test/coverage.test.ts         4      4      0

JSC reports the offsets of executed and unexecuted blocks in code units
of the source string it holds, but ByteRangeMapping looked them up in a
table of byte offsets built from the UTF-8 form of that string, so line
attribution drifted after any non-ASCII text in the module. Today a
non-ASCII preserved comment is enough: every line after it is
misattributed and functions that never ran are reported as covered.

The table now stores each line start in code units (the byte offsets
are kept as-is while the text is ASCII), and the per-row column tables
the line offset table allocates, which were never used here, are freed
instead of being kept for the life of the mapping.

@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.

I reviewed this PR and didn't find any bugs. The encoding math checks out and the test is well-constructed, but since this is a runtime logic change with a struct field replacement and explicit drop_elements() handling, a human sign-off would be worthwhile.

What was reviewed:

  • Verified the code-unit re-measurement is correct for both Latin-1 and UTF-16 JSC strings (© = 1 unit / 2 UTF-8 bytes; 🐰 = 2 units / 4 bytes; element_length_utf8_into_utf16 via simdutf agrees).
  • Checked find_index is a plain binary search over sorted u32 offsets, so feeding it code-unit offsets instead of byte offsets is sound.
  • Confirmed no other consumers of the removed line_offset_table field; Box::from(byte_starts) copies before drop_elements(), and the borrow ends before the &mut call.
  • The comment at test_command.rs:1598 ("owns a MultiArrayList") is now stale but the code it justifies is unchanged and still correct.
Extended reasoning...

Overview

This PR fixes coverage line misattribution for source files containing non-ASCII characters. ByteRangeMapping in src/sourcemap_jsc/CodeCoverage.rs previously stored a LineOffsetTable (byte offsets of line starts) and looked up JSC's code-unit block offsets against it — the two units only agree for pure ASCII. The struct now stores line_starts: Box<[u32]> in UTF-16 code units, computed by re-measuring each line with strings::element_length_utf8_into_utf16 when the source is not all-ASCII. The full LineOffsetTable (including per-line columns_for_non_ascii boxes) is now freed immediately via drop_elements() rather than living in the thread-local map. A regression test in test/cli/test/coverage.test.ts runs identical modules with ASCII vs. ©🐰 banners of equal code-unit length and requires identical lcov DA: records.

Security risks

None. This is pure offset arithmetic on already-decoded module source text; no new inputs, no parsing of untrusted data, no allocation sizing driven by external values beyond what already existed.

Level of scrutiny

Medium. This is a real algorithmic change to runtime code, not a mechanical tweak. The reasoning (JSC 8-bit Latin-1 and 16-bit UTF-16 both round-trip through UTF-8 to the same code-unit count JSC reports) is correct but subtle enough that a maintainer should confirm the approach — particularly since the column_position computed in the sourcemapped branch is now in code units rather than the previous mixed-unit subtraction, which is more correct but interacts with the internal-sourcemap column semantics that PR 4 of the stack (#33866) addresses.

Other factors

  • The struct field change is fully contained: grep confirms no other reader of .line_offset_table on ByteRangeMapping; the only external references to the type (test_command.rs, C++ FFI in ZigSourceProvider.cpp / InternalModuleRegistry.cpp) go through unchanged accessors.
  • Memory handling is sound: Box::from(&[u32]) deep-copies before drop_elements() runs; drop_elements() sets len = 0 so the subsequent MultiArrayList Drop only frees the slab.
  • The test follows harness conventions (tempDir, bunEnv, concurrent spawns with drained stderr, content assertion before exit-code assertion) and asserts specific DA:7,0 / DA:10,0 lines on the ASCII baseline before the equality check, so it cannot pass vacuously.
  • Minor: the loop variable byte_offset and line_start_byte_offset in generate_report_from_blocks are now misnomers (they hold code units), and test_command.rs:1598's comment is stale — neither affects correctness.

Comment thread src/runtime/cli/test_command.rs Outdated
Comment on lines +1598 to +1600
// `ByteRangeMapping` owns its line table and is not `Copy`, so collect
// mutable borrows into the thread-local map instead — no double-free
// risk.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +419 to +421
/// Offset of the start of each line, in UTF-16 code units: the unit JSC
/// reports `BasicBlockRange` offsets in. Equal to the byte offset only
/// while the source text is pure ASCII.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +820 to +822
// `source_contents` is the UTF-8 form of the string JSC holds
// (8-bit or 16-bit), and JSC's offsets count that string's code
// units, so re-measure each line in those.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +837 to +838
// `MultiArrayList`'s own `Drop` frees the slab only; this drops each
// row's `columns_for_non_ascii` box.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun
robobun force-pushed the farm/612517fd/coverage-utf16 branch from f856ca8 to 785825f Compare August 14, 2026 22:22
Comment on lines +1598 to +1599
// `ByteRangeMapping` owns its line table and is not `Copy`, so collect
// mutable borrows into the thread-local map instead; there is no

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@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.

I reviewed this PR and didn't find any bugs. The encoding logic checks out (UTF-16 code-unit count of the UTF-8 re-encoding equals JSC's offset for both 8-bit Latin-1 and 16-bit strings), and the test exercises both BMP and astral characters. Because it's part of a 4-PR stack and the correctness argument hinges on JSC string-representation invariants, a human look would still be worthwhile.

What was reviewed:

  • compute() conversion: verified element_length_utf8_into_utf16 yields the right count for Latin-1→UTF-8 (© = 2 bytes → 1 unit) and surrogate pairs (🐰 = 4 bytes → 2 units), matching JSC's index unit in both storage modes.
  • drop_elements() ordering: byte_starts is copied into the Box<[u32]> before the mutable borrow; the slab itself is freed by MultiArrayList::Drop at scope exit, so no leak or UAF.
  • No other consumers of the removed line_offset_table field; find_index is agnostic to the offset unit.
  • The comment-cop bot flags look like false positives — the flagged comments are 2-3 line field/unit documentation, not workaround justifications.
Extended reasoning...

Overview

This PR changes ByteRangeMapping in src/sourcemap_jsc/CodeCoverage.rs to store line-start offsets in UTF-16 code units instead of UTF-8 bytes, fixing coverage line misattribution when source text contains non-ASCII characters. The struct's line_offset_table: MultiArrayList<...> field becomes line_starts: Box<[u32]>; compute() now branches on is_all_ascii and re-measures each line via element_length_utf8_into_utf16 when needed. A comment in test_command.rs is reworded to match the new field, and a new differential test in coverage.test.ts compares lcov records for an ASCII vs. ©🐰 banner of equal code-unit length.

Security risks

None. This is offline coverage-report post-processing over source text the runtime already loaded; no new parsing of untrusted input, no auth/crypto/network paths.

Level of scrutiny

Medium-high. The change is small in line count but rests on a non-obvious encoding invariant: that for both JSC string kinds (8-bit Latin-1 and 16-bit UTF-16), the UTF-16 code-unit length of the UTF-8 re-encoding equals the index unit JSC reports. I verified this holds (Latin-1 chars are all BMP → 1 unit each; surrogate pairs round-trip to 2 units), and the PR description states it clearly, but it is exactly the kind of claim a maintainer familiar with the JSC source-provider path should confirm. The column_position calculation in the sourcemap branch now subtracts two code-unit values instead of the previous mixed byte/code-unit subtraction — strictly more correct, but worth a maintainer glance since sourcemap column semantics are involved. The drop_elements() addition changes when per-row column boxes are freed; borrow ordering is correct (the &[u32] is consumed before the &mut call) and the slab is still freed by the list's own Drop.

Other factors

  • Second of a 4-PR stack split from #33866; the description notes the helper is shaped for a follow-up variant test, so a human should confirm the split boundaries are what they want.
  • The new test is well-constructed: asserts specific DA:7,0 / DA:10,0 lines on the ASCII baseline before comparing, uses an astral character so code-unit ≠ codepoint ≠ byte, and drains stderr/exit concurrently.
  • comment-cop left five automated "paragraph-long comment" flags; on inspection each is a short (2-3 line) unit-documentation comment rather than a workaround justification, so I read them as bot noise, but they are technically outstanding on the thread.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI for 785825f (#96731): 161 jobs green. The Windows lanes were cancelled because no agent could be created (fleet-wide at the moment), and the x64-asan lane failed on timeouts of require-cache, setInterval and sourcetextmodule-leak, the same leak tests that timed out on the same lane for #38708 and #38714 in the same hour; nothing in this diff runs outside bun test --coverage reporting. Reported for triage; not retriggering while the fleet is saturated.

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Checked the code-unit invariant for both string shapes JSC can hold here: an 8-bit string with characters above 0x7F (to_utf8 re-encodes each byte to two, element_length_utf8_into_utf16 counts one unit each) and a 16-bit string (astral pairs four bytes / two units, lone surrogates U+FFFD / one unit); both are 1:1 with JSC's offsets. Every other offset use in CodeCoverage.rs now compares against the unit table, including the sourcemap-branch column, which matches what Bun's own maps emit. drop_elements is a real method, frees the per-row column boxes MultiArrayList's Drop leaves behind (a small pre-existing leak), and the later Drop is slab-only. The test fails on 1.3.14 exactly as described (DA:7/DA:10 vs DA:4/5/6, FNH identical). Fine to land from my side.

Nits: the body says the diff is two files but the second commit also touches src/runtime/cli/test_command.rs (a comment); is_all_ascii re-scans text LineOffsetTable::generate already classified (its first-non-ASCII column would do), once per file under --coverage so not worth blocking on; repeatUnits silently needs units % piece.length == 0, both call sites satisfy it, a one-line note would guard the follow-up variants.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. Body now mentions the test_command.rs comment. repeatUnits asserts units % piece.length === 0 in #33866, which is where the follow-up variants are added (the helper line is touched there anyway, so the check and its users land together rather than re-spinning this one). Leaving is_all_ascii as is per your note.

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