Compare commits

...

5 Commits

Author SHA1 Message Date
Alistair Smith
1323b8e540 Merge branch 'main' into claude/fix-27955-bytecode-esm-barrel 2026-03-09 19:59:07 -07:00
robobun
05026087b3 fix(watch): fix off-by-one in file:// URL prefix stripping (#27970)
## Summary

- Fix off-by-one error when stripping `file://` prefix in
`node_fs_watcher.zig` and `node_fs_stat_watcher.zig`
- `"file://"` is 7 characters, but `slice[6..]` was used instead of
`slice[7..]`, retaining the second `/`
- Use `slice["file://".len..]` for clarity, matching the existing
pattern in `VirtualMachine.zig:1750`

The bug was masked by downstream path normalization in
`joinAbsStringBufZ` which collapses the duplicate leading slash (e.g.
`//tmp/foo` → `/tmp/foo`).

## Test plan

- [x] Existing `fs.watch` URL tests pass
(`test/js/node/watch/fs.watch.test.ts`)
- [x] Existing `fs.watchFile` URL tests pass
(`test/js/node/watch/fs.watchFile.test.ts`)
- [x] Debug build compiles successfully

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

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 19:52:09 -07:00
Jarred Sumner
767dd2c500 Merge branch 'main' into claude/fix-27955-bytecode-esm-barrel 2026-03-09 14:52:09 -07:00
Claude Bot
84e061ac40 fix: gate unDeferRecord on is_barrel_deferred instead of is_unused
Use the dedicated is_barrel_deferred flag to identify records that were
deferred by barrel optimization, rather than is_unused which is shared
with TypeScript type-only import removal. This ensures unDeferRecord
only acts on records that were actually deferred by barrel optimization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 21:48:40 +00:00
Claude Bot
44d46879f0 fix(bundler): resolve broken binary from --compile --bytecode --format esm with barrel imports
Add a dedicated `is_barrel_deferred` flag to `ImportRecord.Flags` to
reliably identify barrel-optimized-away imports during ModuleInfo
generation for ESM bytecode in --compile builds.

The barrel import optimization (introduced in v1.3.10) marks unused
sub-module imports with `is_unused = true`. However, the `is_unused`
flag is shared with TypeScript type-only import removal and proved
unreliable in release builds when checked during ModuleInfo generation
in `postProcessJSChunk`. This caused barrel-deferred imports to leak
into the serialized ModuleInfo, making the compiled binary try to
resolve internal module paths (e.g., `./diff/base.js`) at runtime,
producing "Cannot find module" errors.

The new `is_barrel_deferred` flag uses the previously unused padding
bit in the packed Flags struct and is set/cleared exclusively by the
barrel optimization, providing an independent signal that is checked
alongside `is_unused` in the ModuleInfo generation step.

Closes #27955

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 21:33:50 +00:00
6 changed files with 169 additions and 10 deletions

View File

@@ -509,7 +509,7 @@ pub const StatWatcher = struct {
defer bun.path_buffer_pool.put(buf);
var slice = args.path.slice();
if (bun.strings.startsWith(slice, "file://")) {
slice = slice[6..];
slice = slice["file://".len..];
}
var parts = [_]string{slice};

View File

@@ -632,7 +632,7 @@ pub const FSWatcher = struct {
const file_path: [:0]const u8 = brk: {
var slice = args.path.slice();
if (bun.strings.startsWith(slice, "file://")) {
slice = slice[6..];
slice = slice["file://".len..];
}
const cwd = bun.fs.FileSystem.instance.top_level_dir;

View File

@@ -157,6 +157,7 @@ fn applyBarrelOptimizationImpl(this: *BundleV2, parse_result: *ParseTask.Result)
if (!needed_records.contains(imp.import_record_index)) {
if (imp.import_record_index < ast.import_records.len) {
ast.import_records.slice()[imp.import_record_index].flags.is_unused = true;
ast.import_records.slice()[imp.import_record_index].flags.is_barrel_deferred = true;
has_deferrals = true;
}
}
@@ -190,12 +191,14 @@ fn applyBarrelOptimizationImpl(this: *BundleV2, parse_result: *ParseTask.Result)
}
}
/// Clear is_unused on a deferred barrel record. Returns true if the record was un-deferred.
/// Clear is_unused and is_barrel_deferred on a deferred barrel record.
/// Returns true if the record was un-deferred.
fn unDeferRecord(import_records: *ImportRecord.List, record_idx: u32) bool {
if (record_idx >= import_records.len) return false;
const rec = &import_records.slice()[record_idx];
if (rec.flags.is_internal or !rec.flags.is_unused) return false;
if (rec.flags.is_internal or !rec.flags.is_barrel_deferred) return false;
rec.flags.is_unused = false;
rec.flags.is_barrel_deferred = false;
return true;
}
@@ -436,7 +439,7 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
if (item.is_star) {
for (barrel_ir.slice(), 0..) |rec, idx| {
if (rec.flags.is_unused and !rec.flags.is_internal) {
if (rec.flags.is_barrel_deferred and !rec.flags.is_internal) {
if (unDeferRecord(barrel_ir, @intCast(idx))) {
try barrels_to_resolve.put(barrels_to_resolve_alloc, barrel_idx, {});
}

View File

@@ -155,10 +155,11 @@ pub fn postProcessJSChunk(ctx: GenerateChunkCtx, worker: *ThreadPool.Worker, chu
// imports by the linker. The printer already recorded them
// when printing cross_chunk_prefix_stmts.
if (record.source_index.isValid()) continue;
// Skip barrel-optimized-away imports — marked is_unused by
// barrel_imports.zig. Never resolved (source_index invalid),
// and removed by convertStmtsForChunk. Not in emitted code.
if (record.flags.is_unused) continue;
// Skip barrel-optimized-away imports — marked is_unused and
// is_barrel_deferred by barrel_imports.zig. Never resolved
// (source_index invalid), and removed by convertStmtsForChunk.
// Not in emitted code.
if (record.flags.is_unused or record.flags.is_barrel_deferred) continue;
const import_path = record.path.text;
const irp_id = mi.str(import_path) catch continue;

View File

@@ -176,7 +176,12 @@ pub const ImportRecord = struct {
wrap_with_to_esm: bool = false,
wrap_with_to_commonjs: bool = false,
_padding: u1 = 0,
/// Set by barrel_imports.zig when this import record is deferred (unused
/// submodule of a barrel file). Unlike `is_unused` (which is shared with
/// TypeScript type-only import removal), this flag is exclusively for
/// barrel optimization and provides a reliable signal for ModuleInfo
/// generation to skip these records.
is_barrel_deferred: bool = false,
};
pub const List = bun.BabyList(ImportRecord);

View File

@@ -0,0 +1,150 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { join } from "path";
// Regression test for https://github.com/oven-sh/bun/issues/27955
// `bun build --compile --bytecode --format esm` produced broken binaries
// when importing from barrel files (packages with sideEffects: false).
// The barrel import optimization deferred unused sub-module imports, but
// the ModuleInfo for ESM bytecode incorrectly included these deferred
// imports as requested modules, causing "Cannot find module" at runtime.
describe("issue #27955: --compile --bytecode --format esm with barrel imports", () => {
const ext = isWindows ? ".exe" : "";
test("named import from barrel package works with --compile --bytecode --format esm", async () => {
// Simulate a barrel package like `diff` with sideEffects: false.
// The barrel index re-exports from multiple sub-modules, but only
// one export is actually used. Barrel optimization should defer the
// unused sub-module imports without breaking the compiled binary.
using dir = tempDir("bytecode-esm-barrel", {
"index.ts": `
import { greet } from './barrel-pkg';
console.log(greet("World"));
`,
"barrel-pkg/package.json": JSON.stringify({
name: "barrel-pkg",
sideEffects: false,
main: "./index.js",
}),
"barrel-pkg/index.js": [
`import { greet } from './greet.js';`,
`import { unused1 } from './unused1.js';`,
`import { unused2 } from './unused2.js';`,
`import { unused3 } from './unused3.js';`,
`export { greet, unused1, unused2, unused3 };`,
].join("\n"),
"barrel-pkg/greet.js": `export function greet(name) { return "Hello, " + name + "!"; }`,
"barrel-pkg/unused1.js": `export function unused1() { return "unused1"; }`,
"barrel-pkg/unused2.js": `export function unused2() { return "unused2"; }`,
"barrel-pkg/unused3.js": `export function unused3() { return "unused3"; }`,
});
const outfile = join(String(dir), `app${ext}`);
// Build with --compile --bytecode --format esm (the failing combination)
await using build = Bun.spawn({
cmd: [
bunExe(),
"build",
"--compile",
"--bytecode",
"--format",
"esm",
join(String(dir), "index.ts"),
"--outfile",
outfile,
],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [buildStdout, buildStderr, buildExitCode] = await Promise.all([
build.stdout.text(),
build.stderr.text(),
build.exited,
]);
expect(buildStderr).toBe("");
expect(buildExitCode).toBe(0);
// Run the compiled executable — this was failing with:
// error: Cannot find module './unused1.js' from '/$bunfs/root/app'
await using exe = Bun.spawn({
cmd: [outfile],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [exeStdout, exeStderr, exeExitCode] = await Promise.all([exe.stdout.text(), exe.stderr.text(), exe.exited]);
expect(exeStdout).toContain("Hello, World!");
expect(exeStderr).not.toContain("Cannot find module");
expect(exeExitCode).toBe(0);
});
test("default import from barrel sub-module works with --compile --bytecode --format esm", async () => {
// The original bug involved `import Diff from './diff/base.js'` pattern
// (default export of a class). Test default imports from barrel sub-modules.
using dir = tempDir("bytecode-esm-barrel-default", {
"index.ts": `
import { create } from './barrel-pkg';
console.log(create());
`,
"barrel-pkg/package.json": JSON.stringify({
name: "barrel-pkg",
sideEffects: false,
main: "./index.js",
}),
"barrel-pkg/index.js": [
`import Base from './base.js';`,
`import Extra from './extra.js';`,
`export function create() { return new Base().name; }`,
`export function createExtra() { return new Extra().name; }`,
].join("\n"),
"barrel-pkg/base.js": `export default class Base { get name() { return "base-ok"; } }`,
"barrel-pkg/extra.js": `export default class Extra { get name() { return "extra"; } }`,
});
const outfile = join(String(dir), `app${ext}`);
await using build = Bun.spawn({
cmd: [
bunExe(),
"build",
"--compile",
"--bytecode",
"--format",
"esm",
join(String(dir), "index.ts"),
"--outfile",
outfile,
],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [, buildStderr, buildExitCode] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]);
expect(buildStderr).toBe("");
expect(buildExitCode).toBe(0);
await using exe = Bun.spawn({
cmd: [outfile],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [exeStdout, exeStderr, exeExitCode] = await Promise.all([exe.stdout.text(), exe.stderr.text(), exe.exited]);
expect(exeStdout).toContain("base-ok");
expect(exeStderr).not.toContain("Cannot find module");
expect(exeExitCode).toBe(0);
});
});