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.
This commit is contained in:
Dylan Conway
2026-01-26 14:02:36 -08:00
committed by GitHub
parent 9d6ef0af1d
commit bd63fb9ef6
3 changed files with 63 additions and 2 deletions

View File

@@ -95,7 +95,7 @@ fn buildOutputPath(path: *bun.AutoAbsPath, config: CPUProfilerConfig, is_md_form
// Append directory if specified // Append directory if specified
if (config.dir.len > 0) { if (config.dir.len > 0) {
path.append(config.dir); path.join(&.{config.dir});
} }
// Append filename // Append filename

View File

@@ -1947,6 +1947,7 @@ pub fn appendOptionsEnv(env: []const u8, comptime ArgType: type, args: *std.arra
// Find the end of the option flag (--flag) // Find the end of the option flag (--flag)
while (j < env.len and !std.ascii.isWhitespace(env[j]) and env[j] != '=') : (j += 1) {} while (j < env.len and !std.ascii.isWhitespace(env[j]) and env[j] != '=') : (j += 1) {}
const end_of_flag = j;
var found_equals = false; var found_equals = false;
// Check for equals sign // Check for equals sign
@@ -1970,6 +1971,10 @@ pub fn appendOptionsEnv(env: []const u8, comptime ArgType: type, args: *std.arra
} else if (found_equals) { } else if (found_equals) {
// If we had --flag=value (no quotes), find next whitespace // If we had --flag=value (no quotes), find next whitespace
while (j < env.len and !std.ascii.isWhitespace(env[j])) : (j += 1) {} while (j < env.len and !std.ascii.isWhitespace(env[j])) : (j += 1) {}
} else {
// No value found after flag (e.g., `--flag1 --flag2`).
// Reset j to end of flag name so we don't include trailing whitespace.
j = end_of_flag;
} }
// Copy the entire argument including quotes // Copy the entire argument including quotes

View File

@@ -1,6 +1,7 @@
import { spawnSync } from "bun"; import { spawnSync } from "bun";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "../../harness"; import { readdirSync } from "fs";
import { bunEnv, bunExe, tempDir } from "harness";
describe("BUN_OPTIONS environment variable", () => { describe("BUN_OPTIONS environment variable", () => {
test("basic usage - passes options to bun command", () => { test("basic usage - passes options to bun command", () => {
@@ -56,6 +57,61 @@ describe("BUN_OPTIONS environment variable", () => {
expect(result.stdout.toString()).toContain("COMMAND LINE"); expect(result.stdout.toString()).toContain("COMMAND LINE");
}); });
test("bare flag before flag with value is recognized", () => {
// Bare flags (no =) that aren't the last option must not get a
// trailing space appended. --cpu-prof is a bare flag; --cpu-prof-dir
// uses = syntax. If --cpu-prof isn't recognized, no profile is written.
using dir = tempDir("bun-options-cpu-prof", {});
const result = spawnSync({
cmd: [bunExe(), "-e", "1"],
env: {
...bunEnv,
BUN_OPTIONS: `--cpu-prof --cpu-prof-dir=${dir}`,
},
});
expect(result.exitCode).toBe(0);
// --cpu-prof should have produced a .cpuprofile file in the dir
const files = readdirSync(String(dir));
const cpuProfiles = files.filter((f: string) => f.endsWith(".cpuprofile"));
expect(cpuProfiles.length).toBeGreaterThanOrEqual(1);
});
test("bare flag before flag with value is recognized (standalone executable)", () => {
// Same test as above but with a compiled standalone executable.
using dir = tempDir("bun-options-cpu-prof-compile", {
"entry.ts": "console.log('ok');",
});
const exePath = String(dir) + "/app";
const profDir = String(dir) + "/profiles";
// Compile
const build = spawnSync({
cmd: [bunExe(), "build", "--compile", String(dir) + "/entry.ts", "--outfile", exePath],
env: bunEnv,
});
expect(build.exitCode).toBe(0);
// Run with BUN_OPTIONS
const result = spawnSync({
cmd: [exePath],
env: {
...bunEnv,
BUN_OPTIONS: `--cpu-prof --cpu-prof-dir=${profDir}`,
},
});
expect(result.stdout.toString()).toContain("ok");
expect(result.exitCode).toBe(0);
const files = readdirSync(profDir);
const cpuProfiles = files.filter((f: string) => f.endsWith(".cpuprofile"));
expect(cpuProfiles.length).toBeGreaterThanOrEqual(1);
});
test("empty BUN_OPTIONS - should work normally", () => { test("empty BUN_OPTIONS - should work normally", () => {
const result = spawnSync({ const result = spawnSync({
cmd: [bunExe(), "--print='NORMAL'"], cmd: [bunExe(), "--print='NORMAL'"],