Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/bun.js/SavedSourceMap.zig
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ pub fn deinit(this: *SavedSourceMap) void {
}

pub fn putMappings(this: *SavedSourceMap, source: *const logger.Source, mappings: MutableString) !void {
// --hot can re-read a file mid-rewrite (truncate + write) and transpile
// a comment-only prefix into a 0-mapping map. Overwriting a real map
// with that would make any still-unreported error from the previous
// transpile remap against nothing and leak transpiled coords. A map
// with no mappings can never answer a lookup, so dropping it is never
// worse than installing it.
if (mappings.list.items.len >= InternalSourceMap.header_size) {
const incoming: InternalSourceMap = .{ .data = mappings.list.items.ptr };
if (incoming.mappingCount() == 0) {
this.lock();
defer this.unlock();
if (this.map.contains(bun.hash(source.path.text))) return;
}
}

const blob = try bun.default_allocator.dupe(u8, mappings.list.items);
errdefer bun.default_allocator.free(blob);
try this.putValue(source.path.text, Value.init(bun.cast(*InternalSourceMap, blob.ptr)));
Expand Down
19 changes: 16 additions & 3 deletions src/bun.js/VirtualMachine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -773,10 +773,23 @@ pub fn reload(this: *VirtualMachine, _: ?*HotReloader.Task) void {
// race through one registry. Defer instead; the main loop reschedules
// via reportExceptionInHotReloadedModuleIfNeeded once the in-flight
// promise resolves or rejects.
//
// Also defer when the previous promise has rejected but its error
// hasn't been printed yet: reloadEntryPoint() re-transpiles and
// overwrites source_mappings[path] in place, so a watcher event that
// slips in between the rejection microtask and the report would remap
// that error against the wrong sourcemap.
if (this.pending_internal_promise) |p| {
if (p.status() == .pending) {
this.hot_reload_deferred = true;
return;
switch (p.status()) {
.pending => {
this.hot_reload_deferred = true;
return;
},
.rejected => if (this.pending_internal_promise_reported_at != this.hot_reload_counter) {
this.hot_reload_deferred = true;
return;
},
.fulfilled => {},
}
}
this.hot_reload_deferred = false;
Expand Down
47 changes: 47 additions & 0 deletions test/cli/hot/hot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,53 @@ ${Buffer.alloc(counter * 2, " ").toString()}throw new Error(${counter});`,
timeout,
);

it(
"should not remap against a stale sourcemap after a partial-file reload",
async () => {
// Regression: the watcher can deliver a second reload Task between the
// moment a module's eval rejects and the moment that rejection is
// printed. The second reload re-transpiles and overwrites
// source_mappings[path] in place, so the still-unreported error gets
// remapped against the wrong map and transpiled coordinates leak
// through — or, since the new pending promise replaces the old one,
// the error is dropped entirely.
//
// To make the window deterministic the hot file truncates itself to a
// comment-only stub immediately before throwing, guaranteeing a fresh
// watcher event lands between reject and report.
const writeFull = (counter: number) =>
writeFileSync(
hotRunnerRoot,
`// source content
${comment_spam}require("fs").writeFileSync(__filename, "// stub ${counter}\\n");
${Buffer.alloc(counter * 2, " ").toString()}throw new Error('${counter}');`,
);
writeFull(0);
await using runner = spawn({
cmd: [bunExe(), "--smol", "--hot", "run", hotRunnerRoot],
env: bunEnv,
cwd,
stdout: "ignore",
stderr: "pipe",
stdin: "ignore",
});
const reloadCounter = await driveErrorReloadCycle(runner, {
targetCount: 20,
onReload: writeFull,
verifyLine: (errorLine, nextLine, counter) => {
if (!nextLine) throw new Error(errorLine);
const match = nextLine.match(/\s*at.*?:(\d+):(\d+)\)?$/);
if (!match) throw new Error("no :line:col in: " + JSON.stringify(nextLine));
if (match[1] !== "1003") throw new Error("expected :1003: but got: " + JSON.stringify(nextLine));
expect(Number(match[2])).toBe(1 + "throw new ".length + counter * 2);
},
});
await runner.exited;
expect(reloadCounter).toBe(20);
},
longTimeout,
);

it(
"should work with sourcemap loading",
async () => {
Expand Down
Loading