Skip to content

error printer: read the source of a code frame as bytes, and delete the loader's print mode - #47770

Open
robobun wants to merge 3 commits into
robobun/1fcef8b1/code-frame-slotsfrom
robobun/c3116916/printer-source-bytes
Open

robobun wants to merge 3 commits into
robobun/1fcef8b1/code-frame-slotsfrom
robobun/c3116916/printer-source-bytes

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #47659. It is the load behind a frame that the cache of #47659 does not hold. No user reported it.

Problem

  • To cut a code frame, the printer gets the module's text from the loader as a WTF string (clone_utf8, src/runtime/jsc_hooks.rs:2882) and turns it back into bytes.
  • 1.3.14 lent the bytes. A print at 48 KB: 10.0 us on 1.3.14, 11.9 us on main.

Fix

  • VirtualMachine::original_source_text returns the bytes. FetchFlags::PrintSource is deleted. The same print: 9.6 us, 115,825 to 88,245 instructions.
  • Correct because 64 of 66 fixtures print the same as the base. The other 2 are the first Downside.
  • Verified: test/js/bun/util/inspect-error.test.js (46 new tests, 5 fail on the base). Other suites: Notes.
  • Self-reviewed: 13 concerns raised, 13 addressed.

Background

Downsides

Notes

Builds. Release builds, Linux x64, one machine: main 3f3a9ff, the base (#47659 at 5708972), this PR, and the released 1.3.14 and 1.4.3. Instruction counts are user-mode from perf_event_open, (I(600 prints) - I(200 prints)) / 400, three runs, lowest to highest.

Instructions per print that loads. The error's .stack was read first, so no build keeps its frame and each print loads.

module, error line main base this PR
111 B 32,235-32,839 32,000-32,789 24,368-24,404
4 KB 44,100-44,755 44,586-44,829 32,151-32,439
48 KB 114,532-115,329 115,825-116,507 88,245-88,725
505 KB 823,141-823,225 837,885-837,926 659,112-659,876
4 MB, line 3 618,512-618,552 618,449-618,498 31,566-31,638
4 MB, line 30,003 3,501,583-3,501,849 3,561,544-3,561,786 2,584,395-2,584,935
4 MB, line 60,003 6,382,310-6,382,609 6,502,466-6,503,331 5,134,727-5,134,876
4 MB, two tabs per line 7,095,348-7,095,633 7,335,136-7,335,271 5,705,923-5,706,062
4 MB, 32 two-byte characters per line 92.65M 92.62M 75.55M
4 MB, CRLF 3,952,022-3,952,073 4,011,885-4,011,928 3,035,307-3,035,354
one line of 2 MB 698,463-698,555 698,277-698,434 649,428-650,141
one line of 2 MB after a tab 30.51M 28.51M 28.46M
4 MB, a byte that is not UTF-8 in the frame, line 3 83.70M 83.70M 33,930-33,964
the same, line 30,003 86.90M 86.96M 2,589,339-2,589,414
4 MB, a lone CR before é in the frame, line 3 6.87M 6.87M 38,070-38,143
the same, line 30,003 9.76M 9.82M 2,589,976-2,590,268
111 B, BUN_DISABLE_SOURCE_CODE_PREVIEW=1 17,247-17,660 17,328-17,782

What is left past line 3 is the line scan (85 instructions per line above the error). It is not in this PR.

A print that uses a kept frame (the same place again), base then this PR: 111 B 24,637-25,187 then 24,140-24,777. 48 KB 26,592-27,516 then 27,190-27,909. 4 MB line 30,003 27,101-27,616 then 27,095-27,636. The ranges overlap.

Wall time per print that loads, microseconds, lowest of 5 interleaved rounds. Main against main in the same run differs by up to 2.4%.

module 1.3.14 1.4.3 main base this PR
111 B 3.0 4.3 4.4 4.4 3.3
4 KB 4.2 5.6 5.4 5.3 4.2
48 KB 10.0 11.9 11.9 12.3 9.6
505 KB 72.4 87.7 89.4 91.1 67.5
4 MB, line 3 201.0 294.4 301.3 309.5 109.6
4 MB, line 30,003 436.8 537.0 551.6 566.0 335.5

Per print that loads, from /proc/self/io and heapStats().mimalloc, base then this PR:

  • allocator heaps made and destroyed: 1 then 0
  • bytes requested from the allocator, 1 MiB module: 2,107,794 then 1,053,473 (ASCII), 3,221,922 then 1,053,489 (not ASCII)
  • allocations: 18.7 then 14.7
  • read() calls: 2 then 2 for a file under 16,383 bytes, 2 then 3 for a longer one. Bytes read: the file size on both.
  • file descriptors left open after 1,001 loads: 0 (a test)

Callers that never print.

  • parse_internal is not touched: parse_internal::<false, false, false> is 8,451 bytes on main and here.
  • transpile_source_code_of_any_provenance::<false>: 18,870 to 17,523 bytes. fetch_without_on_load_plugins: 1,446 to 1,284.
  • Instructions per require() of 2,000 generated modules, five runs, main / base / this PR: .ts 166,199-166,658 / 166,096-166,649 / 166,164-166,698. .json 31,598-32,197 / 31,668-33,027 / 31,733-32,414.
  • #[inline(always)] on transpile_source_code_inner keeps the shape that main compiles to. Without it LLVM inlines the other way once the print-mode branches are gone, and each load copies the 136-byte ResolvedSource once more.
  • Release binary text (size): 140,289,809 to 140,289,020 bytes (-789). original_source_text 1,966 bytes, code_frame_lines_as_utf8 1,026, remap_zig_exception 10,186.

Output. 66 fixtures on Bun.inspect, console.error and the uncaught-error output, base against this PR: 64 equal. The 2 that differ are a lone CR before é and before a byte that is not UTF-8, in a line of the frame. The line cutter ends such a line one byte after the CR. On the base that byte is printed alone (C3, or EF of the U+FFFD that the string made). Here the line is made a string, so it prints U+FFFD. Fixtures: file, JSON, .txt and extension-less sourceURL, eval, stdin, blob:, data:, import suffix, node fallback, macro:, builtin, UTF-8 BOM, UTF-16 BOM, bytes that are not UTF-8 above and inside the frame, CRLF, lone CR, deleted / emptied / shortened file.

How a line that is not UTF-8 is handled. On the base the whole file goes through a string, which puts U+FFFD for such bytes. Here only the lines of the frame are looked at. If one is not UTF-8, the lines of the frame are copied as strings show them, and the frame that #47659 keeps holds that copy. A print with only UTF-8 lines copies nothing.

What the self-review changed. The first form of this change was cut from main. The review asked for: the stack on #47659, no macro around parse_internal (the printer reads its three kinds of source itself), the reader change of src/resolver/fs.rs out of this PR, no whole-file redo for one bad byte, tests that still reach the load with the cache in front (they read error.stack first and count codeFrame_sourceLoads), tests for the entry-point loader, for an executable and for a source that a string cannot hold, no test comment that states a defect of main as a rule, and no multi-line comments. The test for a source that a string cannot hold lowers the limit of a string in place of a 2 GiB file.

Tests.

  • inspect-error.test.js: 46 new tests, 45 of them in the block "the text that a code frame is cut from". On the base 5 fail: the two allocator tests (50 heaps per 50 prints), the two rows for a lone CR (the printer writes half a character), and the row for a text longer than a string can be. The rest hold the output for each kind of source.
  • That last row and its twin lower the limit of a string to 64 KiB with BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT. The base makes the string in C++, which does not read the lowered limit, so it shows the frame there. With the real limit (a sparse file of 2 GiB, release builds) both builds show none.
  • In that block, "node:vm code whose filename names a file" is what the printer shows for node:vm code with a filename. The printer reads that file today (error: do not read a frame's source URL from disk unless the loader loaded it #41477 is about whether it should). The rows hold the output, so that a change of the read does not change it.
  • Debug build: inspect-error.test.js 107 pass, vm-sourceUrl.test.ts, reportError.test.ts, inspect.test.js, bun-inspect.test.ts, run-eval.test.ts, test/js/bun/sourcemap/ (367), import-query.test.ts, bun-build-compile-sourcemap.test.ts, source-too-large.test.ts, test/cli/inspect/inspect.test.ts. clippy on bun_jsc, bun_runtime, bun_bundler.

Not in this PR.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/inspect-error.test.js

When the printer has no kept frame for a place, it asks for the text of
the module to cut the source lines above the error. The module loader
answered that with a print mode: it made a string of the whole file, and
the printer turned the string back into bytes.

`VirtualMachine::original_source_text` gives the printer the bytes: a
virtual source, the body of a `data:` URL, or the file. It transpiles
nothing and makes no string. `FetchFlags` and the arena flag of
`zig_exception::Holder` have no user left and are gone.

A line of the frame that is not UTF-8 becomes a string, so that it shows
U+FFFD as before. The text above the frame is not looked at for that.
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:26 PM PT - Nov 2nd, 2026

❌ @robobun, your commit 89bc108 has 40 failures in Build #136973 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47770

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

bun-47770 --bun

@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How to reproduce:

  • bun bd test test/js/bun/util/inspect-error.test.js -t "the text that a code frame is cut from". On the base (jsc: keep the code frame of a printed error for the next print of the same place #47659 at 5708972) 5 tests fail: the two "a print makes no allocator heap" tests count 50 heaps for 50 prints, the two "a carriage return alone before" tests see half a character on stderr, and "a file over the length that a string can have" shows a frame. On this branch all pass.
  • bun run build:release bench/sourcemap/code-frame-bench.ts on the base and on this branch. The rows "65 places in rotation" and "large module, ..." are prints that load the source.

PR: #47770, stacked on #47659.

CI: build 136973 is red on 28 test files, 27 under test/aot/ and test/bundler/compile-aot-size.test.ts. All of them are also red on main (build 137005). None of them runs the code of this PR. test/js/bun/util/inspect-error.test.js passes on every lane.

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread test/js/bun/util/inspect-error.test.js Resolved
Comment thread test/js/bun/util/inspect-error.test.js Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
A line of the frame that is not UTF-8 grows when it becomes a string:
U+FFFD is three bytes. If the lines then pass 4 GiB, their ranges would
wrap, so the frame is not shown.

Tests: the rows for a carriage return alone check that the printer writes
UTF-8, not which bytes the line cutter puts in the line. The row for a
text that a string cannot hold lowers the limit of a string in place of a
2 GiB file. The executable test runs alone, so it needs no timeout.

@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 re-reviewed after 89bc108 and found no new bugs; because this rewires the error printer's source-loading path through the module loader and deletes the parser-arena reset that Holder::deinit used to perform, a human look would still be worthwhile.

What was reviewed:

  • The u32-overflow guard in code_frame_lines_as_utf8: clearing both ranges and lines drops the already-wrapped ranges, and the empty-code path falls through to collect_source_lines as before.
  • fetch_without_on_load_plugins now resets the arena unconditionally via ArenaResetGuard; original_source_text never touches the transpiler, so no path is left depending on the deleted deferred reset.
  • read_source_text closes the fd the resolver cache leaves open for the watcher, and original_source_text caps text at String::max_length() before the u32 line ranges are cut.
  • The reworked tests replace the 2 GiB sparse-file case with BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT and move the compile test out of the concurrent block without a timeout.
Extended reasoning...

The diff touches src/jsc/VirtualMachine.rs (about 300 lines), src/runtime/jsc_hooks.rs, ModuleLoader.rs, ZigException.rs, ConsoleObject.rs and a 500-line test block, replacing the transpiler-backed PrintSource fetch with a raw byte read for code frames and deleting FetchFlags and the Holder-driven parser arena reset. It touches no auth, crypto or injection surface; the sensitive part is memory ownership (arena reset timing, fd handling, u32 line ranges over possibly multi-GiB text). The latest commit addresses the blocking overflow finding from the prior review and the hunt ran dry, but the change is large, authored by a bot account, and alters unconditional-vs-conditional behaviour in the module loader (watcher registration and vitest checks no longer gated), so a maintainer should still look rather than this being approved automatically.

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