Repository navigation
jsc: keep the code frame of a printed error for the next print of the same place #47659
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
robobun
wants to merge
2
commits into
main
Choose a base branch
from
robobun/1fcef8b1/code-frame-slots
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| // Microbench for the code frame of a printed error: the source lines that | ||
| // Bun.inspect(error) and console.error(error) show above the message. Compare | ||
| // against a baseline build by running: | ||
| // | ||
| // bun run build:release bench/sourcemap/code-frame-bench.ts | ||
| // | ||
| // The printer cuts the frame from the whole source text. For a module that bun | ||
| // transpiled it reads the file. For a module that has a source map (a | ||
| // `bun build --target=bun --sourcemap` bundle) it parses the map for | ||
| // `sourcesContent`. Both loads are O(size), so each shape runs small and large. | ||
|
|
||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| const flag = process.argv[2] ?? "bench"; | ||
| // The printer keeps the frames of this many places (`CodeFrameSlots::CAPACITY`). | ||
| const KEPT = 64; | ||
| const LARGE_BYTES = 4 * 1024 * 1024; | ||
|
|
||
| const dir = fs.mkdtempSync(path.join(os.tmpdir(), "code-frame-bench-")); | ||
| process.on("exit", () => fs.rmSync(dir, { recursive: true, force: true })); | ||
|
|
||
| type Places = { sites: (() => Error)[]; long: () => Error }; | ||
|
|
||
| // `sites[i]()` returns an error thrown on its own line. `long()` throws on a | ||
| // line of 5000 bytes. Comment lines before and after them pad the source to | ||
| // `bytes`, so the places are in the middle of a large source. | ||
| function placesSource(bytes: number): string { | ||
| let places = "export const sites: (() => Error)[] = [\n"; | ||
| for (let i = 0; i <= KEPT; i++) { | ||
| places += ` () => { try { throw new Error("site ${i}"); } catch (e) { return e as Error; } },\n`; | ||
| } | ||
| places += "];\n"; | ||
| places += `export function long(): Error { try { throw new Error("long"); } catch (e) { return e as Error; } } // ${Buffer.alloc(5000, "p")}\n`; | ||
| const line = "// padding padding padding padding padding padding padding padding\n"; | ||
| const lines = Math.max(0, bytes - places.length) / 2 / line.length; | ||
| const padding = Buffer.alloc(Math.floor(lines) * line.length, line).toString(); | ||
| return padding + places + padding; | ||
| } | ||
|
|
||
| // Microseconds per call of `fn(i)`. A pass is `callsPerPass` calls, and only | ||
| // whole passes are measured, for at least 250 ms. | ||
| function measure(callsPerPass: number, fn: (i: number) => unknown): string { | ||
| const start = performance.now(); | ||
| let elapsed = 0; | ||
| let calls = 0; | ||
| do { | ||
| for (let i = 0; i < callsPerPass; i++) fn(i); | ||
| calls += callsPerPass; | ||
| elapsed = performance.now() - start; | ||
| } while (elapsed < 250); | ||
| return ((elapsed * 1000) / calls).toFixed(2) + " µs/op"; | ||
| } | ||
|
|
||
| function report(title: string, file: string, loadedBytes: number) { | ||
| const { sites, long } = require(file) as Places; | ||
| const print = (i: number) => Bun.inspect(sites[i]()); | ||
|
|
||
| const start = performance.now(); | ||
| for (let i = 0; i < KEPT; i++) print(i); | ||
| const first = (((performance.now() - start) * 1000) / KEPT).toFixed(2) + " µs/op"; | ||
|
|
||
| const rows: [string, string][] = [["first print of a place", first]]; | ||
| rows.push(["the same place again", measure(KEPT, () => print(0))]); | ||
| rows.push([`${KEPT} places in rotation`, measure(KEPT, print)]); | ||
| // With one more place than is kept, each print follows `KEPT` other places. | ||
| for (let i = 0; i <= KEPT; i++) print(i); | ||
| rows.push([`${KEPT + 1} places in rotation`, measure(KEPT + 1, print)]); | ||
| rows.push(["a line of 5000 bytes", measure(16, () => Bun.inspect(long()))]); | ||
|
|
||
| console.log(`[${flag}] ${title}, ${(loadedBytes / 1024).toFixed(0)} KB loaded per cut`); | ||
| for (const [name, value] of rows) console.log(`[${flag}] ${name.padEnd(24)} ${value.padStart(16)}`); | ||
| } | ||
|
|
||
| // The first print of a process does work that no later print does. | ||
| for (let i = 0; i < 5; i++) Bun.inspect(new Error("warm-up")); | ||
|
|
||
| for (const [size, bytes] of [ | ||
| ["small", 0], | ||
| ["large", LARGE_BYTES], | ||
| ] as const) { | ||
| const source = path.join(dir, `${size}.ts`); | ||
| fs.writeFileSync(source, placesSource(bytes)); | ||
| report(`${size} module that bun transpiled`, source, fs.statSync(source).size); | ||
|
|
||
| // The bundle gets its own copy of the source, so that it shares no module | ||
| // with the run above. | ||
| const entry = path.join(dir, `${size}-bundled.ts`); | ||
| fs.copyFileSync(source, entry); | ||
| const build = await Bun.build({ | ||
| entrypoints: [entry], | ||
| outdir: path.join(dir, "out"), | ||
| target: "bun", | ||
| sourcemap: "external", | ||
| }); | ||
| if (!build.success) throw new AggregateError(build.logs, "the bundle of the fixture failed"); | ||
| const bundle = path.join(dir, "out", `${size}-bundled.js`); | ||
| report(`${size} bundle with an external source map`, bundle, fs.statSync(bundle + ".map").size); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 (optional) After merging, a developer whose long-running process prints an error from a file edited since it loaded sees the old source lines, and one error chain can show two versions of the same file. The ByPath slot stored at src/jsc/VirtualMachine.rs:7351 is a copy of the module's file on disk keyed only by map identity and line (src/jsc/rare_data.rs:213), with no mtime, size or hash, so an edit without a re-transpile never invalidates it. Fix: a file-keyed slot must carry the file's identity at load time (stat mtime+size, or a content hash of the read) and miss when it differs; or do not keep ByPath frames at all.
Why this was flagged
Trigger: a process run without --hot (plain
bun run server.ts,bun test, a CLI tool) prints an error from module M at line L, the developer edits M on disk, and the process prints an error from M again. The second print of the same (map, ByPath, L) place hits src/jsc/rare_data.rs:213 and copies the old bytes (src/jsc/VirtualMachine.rs:7300-7304); a print from another line of M misses and reads the current file (src/jsc/VirtualMachine.rs:7306-7325). Onecausechain therefore showsif (n > 99)in one frame andif (n > 10)in another, which the PR description itself concedes. On the base branch every print reads the file (baseget_lines_in_textover a fresh fetch), so all frames of one print show one version. The invalidation that is relied on (SavedSourceMap::put_valuereplacing the Arc, src/jsc/SavedSourceMap.rs:233) only fires on a re-transpile, which a plain edit never causes. The test at test/js/bun/util/inspect-error.test.js:821 pinseditedOnDisk: true, certifying the stale read.Verification:
getmatches purely oncore::ptr::eq(slot.map.as_ptr(), Arc::as_ptr(map)) && slot.source == source && slot.line == line(rare_data.rs). A later print of the same (map, ByPath, L) hits at lines 7300-7304 and copies the old bytes, while a print of another line of the same module misses and reads the current file (7306-7325), so one chain/process can show two versions of the file.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is difference 3 in the description (first block of Notes). It is open for the maintainer who asked for the change: the request pins the text until the module loads again.
The two other options:
statfor each kept print of a by-path frame, with the mtime and the size stored at the load. That costs one syscall per kept print and one per first print. The output is then the same as on main, except for an edit that keeps both values.I will build the one that is chosen.