Files
bun.sh/src/bake/DevServer/ErrorReportRequest.zig
Jarred Sumner 2eebcee522 Fix DevServer HMR sourcemap offset issues (#22739)
## Summary
Fixes sourcemap offset issues in DevServer HMR mode that were causing
incorrect line number mappings when debugging.

## Problem

When using DevServer with HMR enabled, sourcemap line numbers were
consistently off by one or more lines when shown in Chrome DevTools. In
some cases, they were off when shown in the terminal as well.

## Solution

### 1. Remove magic +2 offset
Removed an arbitrary "+2" that was added to `runtime.line_count` in
SourceMapStore.zig. The comment said "magic fairy in my dreams said it
would align the source maps" - this was causing positions to be
incorrectly offset.

### 2. Fix double-increment bug
ErrorReportRequest.zig was incorrectly adding 1 to line numbers that
were already 1-based from the browser, causing an off-by-one error.

### 3. Improve type safety
Converted all line/column handling to use `bun.Ordinal` type instead of
raw `i32`, ensuring consistent 0-based vs 1-based conversions throughout
the codebase.

## Test plan
- [x] Added comprehensive sourcemap tests for complex error scenarios
- [x] Tested with React applications in dev mode
- [x] Verified line numbers match correctly in browser dev tools
- [x] Existing sourcemap tests continue to pass

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-17 15:37:09 -07:00

405 lines
16 KiB
Zig

/// Fetched when a client-side error happens. This performs two actions
/// - Logs the remapped stack trace to the console.
/// - Replies with the remapped stack trace.
/// Payload:
/// - `u32`: Responding message ID (echoed back)
/// - `u32`: Length of message
/// - `[n]u8`: Message
/// - `u32`: Length of error name
/// - `[n]u8`: Error name
/// - `u32`: Number of stack frames. For each
/// - `u32`: Line number (0 for unavailable)
/// - `u32`: Column number (0 for unavailable)
/// - `u32`: Length of file name (0 for unavailable)
/// - `[n]u8`: File name
/// - `u32`: Length of function name (0 for unavailable)
/// - `[n]u8`: Function name
const ErrorReportRequest = @This();
dev: *DevServer,
body: uws.BodyReaderMixin(@This(), "body", runWithBody, finalize),
pub fn run(dev: *DevServer, _: *Request, resp: anytype) void {
const ctx = bun.new(ErrorReportRequest, .{
.dev = dev,
.body = .init(dev.allocator()),
});
ctx.dev.server.?.onPendingRequest();
ctx.body.readBody(resp);
}
pub fn finalize(ctx: *ErrorReportRequest) void {
ctx.dev.server.?.onStaticRequestComplete();
bun.destroy(ctx);
}
pub fn runWithBody(ctx: *ErrorReportRequest, body: []const u8, r: AnyResponse) !void {
// .finalize has to be called last, but only in the non-error path.
var should_finalize_self = false;
defer if (should_finalize_self) ctx.finalize();
var s = std.io.fixedBufferStream(body);
const reader = s.reader();
var sfa_general = std.heap.stackFallback(65536, ctx.dev.allocator());
var sfa_sourcemap = std.heap.stackFallback(65536, ctx.dev.allocator());
const temp_alloc = sfa_general.get();
var arena = std.heap.ArenaAllocator.init(temp_alloc);
defer arena.deinit();
var source_map_arena = std.heap.ArenaAllocator.init(sfa_sourcemap.get());
defer source_map_arena.deinit();
// Read payload, assemble ZigException
const name = try readString32(reader, temp_alloc);
defer temp_alloc.free(name);
const message = try readString32(reader, temp_alloc);
defer temp_alloc.free(message);
const browser_url = try readString32(reader, temp_alloc);
defer temp_alloc.free(browser_url);
var frames: ArrayListUnmanaged(jsc.ZigStackFrame) = .empty;
defer frames.deinit(temp_alloc);
const stack_count = @min(try reader.readInt(u32, .little), 255); // does not support more than 255
try frames.ensureTotalCapacity(temp_alloc, stack_count);
for (0..stack_count) |_| {
const line = try reader.readInt(i32, .little);
const column = try reader.readInt(i32, .little);
const function_name = try readString32(reader, temp_alloc);
const file_name = try readString32(reader, temp_alloc);
frames.appendAssumeCapacity(.{
.function_name = .init(function_name),
.source_url = .init(file_name),
.position = if (line > 0) .{
.line = .fromOneBased(line),
.column = if (column < 1) .invalid else .fromOneBased(column),
.line_start_byte = 0,
} else .{
.line = .invalid,
.column = .invalid,
.line_start_byte = 0,
},
.code_type = .None,
.is_async = false,
.remapped = false,
});
}
const runtime_name = "Bun HMR Runtime";
const browser_url_origin = bun.jsc.URL.originFromSlice(browser_url) orelse browser_url;
// All files that DevServer could provide a source map fit the pattern:
// `/_bun/client/<label>-{u64}.js`
// Where the u64 is a unique identifier pointing into sourcemaps.
//
// HMR chunks use this too, but currently do not host their JS code.
var parsed_source_maps: AutoArrayHashMapUnmanaged(SourceMapStore.Key, ?SourceMapStore.GetResult) = .empty;
try parsed_source_maps.ensureTotalCapacity(temp_alloc, 4);
defer for (parsed_source_maps.values()) |*value| {
if (value.*) |*v| v.deinit(temp_alloc);
};
var runtime_lines: ?[5][]const u8 = null;
var first_line_of_interest: usize = 0;
var top_frame_position: jsc.ZigStackFramePosition = undefined;
var region_of_interest_line: u32 = 0;
for (frames.items) |*frame| {
const source_url = frame.source_url.value.ZigString.slice();
// The browser code strips "http://localhost:3000" when the string
// has /_bun/client. It's done because JS can refer to `location`
const id = parseId(source_url, browser_url_origin) orelse continue;
// Get and cache the parsed source map
const gop = try parsed_source_maps.getOrPut(temp_alloc, id);
if (!gop.found_existing) {
defer _ = source_map_arena.reset(.retain_capacity);
const psm = ctx.dev.source_maps.getParsedSourceMap(
id,
source_map_arena.allocator(), // arena for parsing
temp_alloc, // store results into first arena
) orelse {
Output.debugWarn("Failed to find mapping for {s}, {d}", .{ source_url, id.get() });
gop.value_ptr.* = null;
continue;
};
gop.value_ptr.* = psm;
}
const result: *const SourceMapStore.GetResult = &(gop.value_ptr.* orelse continue);
// When before the first generated line, remap to the HMR runtime.
//
// Reminder that the HMR runtime is *not* sourcemapped. And appears
// first in the bundle. This means that the mappings usually looks like
// this:
//
// AAAA;;;;;;;;;;;ICGA,qCAA4B;
// ^ ^ generated_mappings[1], actual code
// ^
// ^ generated_mappings[0], we always start it with this
//
// So we can know if the frame is inside the HMR runtime if
// `frame.position.line < generated_mappings[1].lines`.
const generated_mappings = result.mappings.generated();
if (generated_mappings.len <= 1 or frame.position.line.zeroBased() < generated_mappings[1].lines.zeroBased()) {
frame.source_url = .init(runtime_name); // matches value in source map
frame.position = .invalid;
continue;
}
// Remap the frame
const remapped = result.mappings.find(
frame.position.line,
frame.position.column,
);
if (remapped) |*remapped_position| {
frame.position = .{
.line = .fromZeroBased(remapped_position.originalLine()),
.column = .fromZeroBased(remapped_position.originalColumn()),
.line_start_byte = 0,
};
const index = remapped_position.source_index;
if (index >= 1 and (index - 1) < result.file_paths.len) {
const abs_path = result.file_paths[@intCast(index - 1)];
frame.source_url = .init(abs_path);
const relative_path_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(relative_path_buf);
const rel_path = ctx.dev.relativePath(relative_path_buf, abs_path);
if (bun.strings.eql(frame.function_name.value.ZigString.slice(), rel_path)) {
frame.function_name = .empty;
}
frame.remapped = true;
if (runtime_lines == null) {
const file = result.entry_files.get(@intCast(index - 1));
if (file.get()) |source_map| {
const json_encoded_source_code = source_map.quotedContents();
// First line of interest is two above the target line.
const target_line = @as(usize, @intCast(frame.position.line.zeroBased()));
first_line_of_interest = target_line -| 2;
region_of_interest_line = @intCast(target_line - first_line_of_interest);
runtime_lines = try extractJsonEncodedSourceCode(
json_encoded_source_code,
@intCast(first_line_of_interest),
5,
arena.allocator(),
);
top_frame_position = frame.position;
}
}
} else if (index == 0) {
// Should be picked up by above but just in case.
frame.source_url = .init(runtime_name);
frame.position = .invalid;
}
}
}
// Stack traces can often end with random runtime frames that are not relevant.
trim_runtime_frames: {
// Ensure that trimming will not remove ALL frames.
for (frames.items) |frame| {
if (!frame.position.isInvalid() or frame.source_url.value.ZigString.slice().ptr != runtime_name) {
break;
}
} else break :trim_runtime_frames;
// Move all frames up
var i: usize = 0;
for (frames.items[i..]) |frame| {
if (frame.position.isInvalid() and frame.source_url.value.ZigString.slice().ptr == runtime_name) {
continue; // skip runtime frames
}
frames.items[i] = frame;
i += 1;
}
frames.items.len = i;
}
var exception: jsc.ZigException = .{
.type = .Error,
.runtime_type = .Nothing,
.name = .init(name),
.message = .init(message),
.stack = .fromFrames(frames.items),
.exception = null,
.remapped = false,
.browser_url = .init(browser_url),
};
const stderr = Output.errorWriterBuffered();
defer Output.flush();
switch (Output.enable_ansi_colors_stderr) {
inline else => |ansi_colors| ctx.dev.vm.printExternallyRemappedZigException(
&exception,
null,
@TypeOf(stderr),
stderr,
true,
ansi_colors,
) catch {},
}
var out: std.ArrayList(u8) = .init(ctx.dev.allocator());
errdefer out.deinit();
const w = out.writer();
try w.writeInt(u32, exception.stack.frames_len, .little);
for (exception.stack.frames()) |frame| {
try w.writeInt(i32, frame.position.line.oneBased(), .little);
try w.writeInt(i32, frame.position.column.oneBased(), .little);
const function_name = frame.function_name.value.ZigString.slice();
try w.writeInt(u32, @intCast(function_name.len), .little);
try w.writeAll(function_name);
const src_to_write = frame.source_url.value.ZigString.slice();
if (bun.strings.hasPrefixComptime(src_to_write, "/")) {
const relative_path_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(relative_path_buf);
const file = ctx.dev.relativePath(relative_path_buf, src_to_write);
try w.writeInt(u32, @intCast(file.len), .little);
try w.writeAll(file);
} else {
try w.writeInt(u32, @intCast(src_to_write.len), .little);
try w.writeAll(src_to_write);
}
}
if (runtime_lines) |*lines| {
// trim empty lines
var adjusted_lines: [][]const u8 = lines;
while (adjusted_lines.len > 0 and adjusted_lines[0].len == 0) {
adjusted_lines = adjusted_lines[1..];
region_of_interest_line -|= 1;
first_line_of_interest += 1;
}
while (adjusted_lines.len > 0 and adjusted_lines[adjusted_lines.len - 1].len == 0) {
adjusted_lines.len -= 1;
}
try w.writeInt(u8, @intCast(adjusted_lines.len), .little);
try w.writeInt(u32, @intCast(region_of_interest_line), .little);
try w.writeInt(u32, @intCast(first_line_of_interest + 1), .little);
try w.writeInt(u32, @intCast(top_frame_position.column.oneBased()), .little);
for (adjusted_lines) |line| {
try w.writeInt(u32, @intCast(line.len), .little);
try w.writeAll(line);
}
} else {
try w.writeInt(u8, 0, .little);
}
StaticRoute.sendBlobThenDeinit(r, &.fromArrayList(out), .{
.mime_type = &.other,
.server = ctx.dev.server.?,
});
should_finalize_self = true;
}
pub fn parseId(source_url: []const u8, browser_url: []const u8) ?SourceMapStore.Key {
if (!bun.strings.startsWith(source_url, browser_url))
return null;
const after_host = source_url[bun.strings.withoutTrailingSlash(browser_url).len..];
if (!bun.strings.hasPrefixComptime(after_host, client_prefix ++ "/"))
return null;
const after_prefix = after_host[client_prefix.len + 1 ..];
// Extract the ID
if (!bun.strings.hasSuffixComptime(after_prefix, ".js"))
return null;
const min_len = "00000000FFFFFFFF.js".len;
if (after_prefix.len < min_len)
return null;
const hex = after_prefix[after_prefix.len - min_len ..][0 .. @sizeOf(u64) * 2];
if (hex.len != @sizeOf(u64) * 2)
return null;
return .init(DevServer.parseHexToInt(u64, hex) orelse
return null);
}
/// Instead of decoding the entire file, just decode the desired section.
fn extractJsonEncodedSourceCode(contents: []const u8, target_line: u32, comptime n: usize, arena: Allocator) !?[n][]const u8 {
var line: usize = 0;
var prev: usize = 0;
const index_of_first_line = if (target_line == 0)
0 // no iteration needed
else while (bun.strings.indexOfCharPos(contents, '\\', prev)) |i| : (prev = i + 2) {
if (i >= contents.len - 2) return null;
// Bun's JSON printer will not use a sillier encoding for newline.
if (contents[i + 1] == 'n') {
line += 1;
if (line == target_line)
break i + 2;
}
} else return null;
var rest = contents[index_of_first_line..];
// For decoding JSON escapes, the JS Lexer decoding function has
// `decodeEscapeSequences`, which only supports decoding to UTF-16.
// Alternatively, it appears the TOML lexer has copied this exact
// function but for UTF-8. So the decoder can just use that.
//
// This function expects but does not assume the escape sequences
// given are valid, and does not bubble errors up.
var log = Log.init(arena);
var l: bun.interchange.toml.Lexer = .{
.log = &log,
.source = .initEmptyFile(""),
.allocator = arena,
.should_redact_logs = false,
.prev_error_loc = .Empty,
};
defer log.deinit();
var result: [n][]const u8 = .{""} ** n;
for (&result) |*decoded_line| {
var has_extra_escapes = false;
prev = 0;
// Locate the line slice
const end_of_line = while (bun.strings.indexOfCharPos(rest, '\\', prev)) |i| : (prev = i + 2) {
if (i >= rest.len - 1) return null;
if (rest[i + 1] == 'n') {
break i;
}
has_extra_escapes = true;
} else rest.len;
const encoded_line = rest[0..end_of_line];
// Decode it
if (has_extra_escapes) {
var bytes: std.ArrayList(u8) = try .initCapacity(arena, encoded_line.len);
try l.decodeEscapeSequences(0, encoded_line, false, std.ArrayList(u8), &bytes);
decoded_line.* = bytes.items;
} else {
decoded_line.* = encoded_line;
}
if (end_of_line + 2 >= rest.len) break;
rest = rest[end_of_line + 2 ..];
}
return result;
}
const bun = @import("bun");
const Output = bun.Output;
const bake = bun.bake;
const jsc = bun.jsc;
const Log = bun.logger.Log;
const StaticRoute = bun.api.server.StaticRoute;
const DevServer = bake.DevServer;
const SourceMapStore = DevServer.SourceMapStore;
const client_prefix = DevServer.client_prefix;
const readString32 = DevServer.readString32;
const uws = bun.uws;
const AnyResponse = bun.uws.AnyResponse;
const Request = uws.Request;
const std = @import("std");
const ArrayListUnmanaged = std.ArrayListUnmanaged;
const AutoArrayHashMapUnmanaged = std.AutoArrayHashMapUnmanaged;
const Allocator = std.mem.Allocator;