Files
bun.sh/src/bun.js/bindings/BunCPUProfiler.zig
Dylan Conway bd63fb9ef6 fix: BUN_OPTIONS bare flags getting trailing whitespace (#26464)
## Summary

Fix a bug in `appendOptionsEnv` where bare flags (no `=`) that aren't
the last option get a trailing space appended, causing the argument
parser to not recognize them.

For example, `BUN_OPTIONS="--cpu-prof --cpu-prof-dir=profiles"` would
parse `--cpu-prof` as `"--cpu-prof "` (trailing space), so CPU profiling
was never enabled.

## Root Cause

When `appendOptionsEnv` encounters a `--flag` followed by whitespace, it
advances past the whitespace looking for a possible quoted value (e.g.
`--flag "quoted"`). If no quote is found and there's no `=`, it falls
through without resetting `j`, so the emitted argument includes the
trailing whitespace.

## Fix

Save `end_of_flag = j` after scanning the flag name. Add an `else`
branch that resets `j = end_of_flag` when no value (quote or `=`) is
found after the whitespace. This is a 3-line change.

Also fixes a separate bug in `BunCPUProfiler.zig` where `--cpu-prof-dir`
with an absolute path would hit a debug assertion (`path.append` on an
already-rooted path with an absolute input). Changed to `path.join`
which handles both relative and absolute paths correctly.

## Tests

- `test/cli/env/bun-options.test.ts`: Two new tests verifying
`--cpu-prof --cpu-prof-dir=<abs-path>` produces a `.cpuprofile` file,
for both normal and standalone compiled executables.
2026-01-26 14:02:36 -08:00

129 lines
4.6 KiB
Zig

pub const CPUProfilerConfig = struct {
name: []const u8,
dir: []const u8,
md_format: bool = false,
json_format: bool = false,
};
// C++ function declarations
extern fn Bun__startCPUProfiler(vm: *jsc.VM) void;
extern fn Bun__stopCPUProfiler(vm: *jsc.VM, outJSON: ?*bun.String, outText: ?*bun.String) void;
pub fn startCPUProfiler(vm: *jsc.VM) void {
Bun__startCPUProfiler(vm);
}
pub fn stopAndWriteProfile(vm: *jsc.VM, config: CPUProfilerConfig) !void {
var json_string: bun.String = .empty;
var text_string: bun.String = .empty;
// Call the unified C++ function with pointers for requested formats
Bun__stopCPUProfiler(
vm,
if (config.json_format) &json_string else null,
if (config.md_format) &text_string else null,
);
defer json_string.deref();
defer text_string.deref();
// Write JSON format if requested and not empty
if (config.json_format and !json_string.isEmpty()) {
try writeProfileToFile(json_string, config, false);
}
// Write text format if requested and not empty
if (config.md_format and !text_string.isEmpty()) {
try writeProfileToFile(text_string, config, true);
}
}
fn writeProfileToFile(profile_string: bun.String, config: CPUProfilerConfig, is_md_format: bool) !void {
const profile_slice = profile_string.toUTF8(bun.default_allocator);
defer profile_slice.deinit();
// Determine the output path using AutoAbsPath
var path_buf: bun.AutoAbsPath = .initTopLevelDir();
defer path_buf.deinit();
try buildOutputPath(&path_buf, config, is_md_format);
// Convert to OS-specific path (UTF-16 on Windows, UTF-8 elsewhere)
var path_buf_os: bun.OSPathBuffer = undefined;
const output_path_os: bun.OSPathSliceZ = if (bun.Environment.isWindows)
bun.strings.convertUTF8toUTF16InBufferZ(&path_buf_os, path_buf.sliceZ())
else
path_buf.sliceZ();
// Write the profile to disk using bun.sys.File.writeFile
const result = bun.sys.File.writeFile(bun.FD.cwd(), output_path_os, profile_slice.slice());
if (result.asErr()) |err| {
// If we got ENOENT, PERM, or ACCES, try creating the directory and retry
const errno = err.getErrno();
if (errno == .NOENT or errno == .PERM or errno == .ACCES) {
if (config.dir.len > 0) {
bun.FD.cwd().makePath(u8, config.dir) catch {};
// Retry write
const retry_result = bun.sys.File.writeFile(bun.FD.cwd(), output_path_os, profile_slice.slice());
if (retry_result.asErr()) |_| {
return error.WriteFailed;
}
} else {
return error.WriteFailed;
}
} else {
return error.WriteFailed;
}
}
}
fn buildOutputPath(path: *bun.AutoAbsPath, config: CPUProfilerConfig, is_md_format: bool) !void {
// Generate filename
var filename_buf: bun.PathBuffer = undefined;
// If both formats are being written and a custom name was specified,
// we need to add the appropriate extension to disambiguate
const has_both_formats = config.md_format and config.json_format;
const filename = if (config.name.len > 0) blk: {
if (has_both_formats) {
// Custom name with both formats - append extension based on format
const ext = if (is_md_format) ".md" else ".cpuprofile";
break :blk std.fmt.bufPrint(&filename_buf, "{s}{s}", .{ config.name, ext }) catch return error.FilenameTooLong;
} else {
break :blk config.name;
}
} else try generateDefaultFilename(&filename_buf, is_md_format);
// Append directory if specified
if (config.dir.len > 0) {
path.join(&.{config.dir});
}
// Append filename
path.append(filename);
}
fn generateDefaultFilename(buf: *bun.PathBuffer, md_format: bool) ![]const u8 {
// Generate filename like: CPU.{timestamp}.{pid}.cpuprofile (or .md for markdown format)
// Use microsecond timestamp for uniqueness
const timespec = bun.timespec.now(.force_real_time);
const pid = if (bun.Environment.isWindows)
std.os.windows.GetCurrentProcessId()
else
std.c.getpid();
const epoch_microseconds: u64 = @intCast(timespec.sec *% 1_000_000 +% @divTrunc(timespec.nsec, 1000));
const extension = if (md_format) ".md" else ".cpuprofile";
return std.fmt.bufPrint(buf, "CPU.{d}.{d}{s}", .{
epoch_microseconds,
pid,
extension,
}) catch return error.FilenameTooLong;
}
const std = @import("std");
const bun = @import("bun");
const jsc = bun.jsc;