From ba6e84fecd024a5eecbc2b97109dd3c5a59ac42d Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 10 Feb 2026 22:32:31 -0800 Subject: [PATCH] fix(compile): seek to start of file before EXDEV cross-device copy (#26883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What does this PR do? Fixes `bun build --compile` producing an all-zeros binary when the output directory is on a different filesystem than the temp directory. This is common in Docker containers, Gitea runners, and other environments using overlayfs. ## Problem When `inject()` finishes writing the modified executable to the temp file, the file descriptor's offset is at EOF. If the subsequent `renameat()` to the output path fails with `EXDEV` (cross-device — the temp file and output dir are on different filesystems), the code falls back to `copyFileZSlowWithHandle()`, which: 1. Calls `fallocate()` to pre-allocate the output file to the correct size (filled with zeros) 2. Calls `bun.copyFile(in_handle, out_handle)` — but `in_handle`'s offset is at EOF 3. `copy_file_range` / `sendfile` / `read` all use the current file offset (EOF), read 0 bytes, and return immediately 4. Result: output file is the correct size but entirely zeros This explains user reports of `bun build --compile --target=bun-darwin-arm64` producing invalid binaries that `file` identifies as "data" rather than a Mach-O executable. ## Fix Seek the input fd to offset 0 in `copyFileZSlowWithHandle` before calling `bun.copyFile`. ## How did you verify your code works? - `bun bd` compiles successfully - `bun bd test test/bundler/bun-build-compile.test.ts` — 6/6 pass - Added tests that verify compiled binaries have valid executable headers and produce correct output - Manually verified cross-compilation: `bun build --compile --target=bun-darwin-arm64` produces a valid Mach-O binary 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot Co-authored-by: Claude --- src/sys.zig | 6 +++ test/bundler/bun-build-compile.test.ts | 67 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/sys.zig b/src/sys.zig index 285878683d..cb606398f4 100644 --- a/src/sys.zig +++ b/src/sys.zig @@ -4092,6 +4092,12 @@ pub fn copyFileZSlowWithHandle(in_handle: bun.FileDescriptor, to_dir: bun.FileDe _ = std.os.linux.fallocate(out_handle.cast(), 0, 0, @intCast(stat_.size)); } + // Seek input to beginning — the caller may have written to this fd, + // leaving the file offset at EOF. copy_file_range / sendfile / read + // all use the current offset when called with null offsets. + // Ignore errors: the fd may be non-seekable (e.g. a pipe). + _ = setFileOffset(in_handle, 0); + switch (bun.copyFile(in_handle, out_handle)) { .err => |e| return .{ .err = e }, .result => {}, diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 8004a7bb78..491e3b8438 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -121,4 +121,71 @@ describe("Bun.build compile", () => { }); }); +describe("compiled binary validity", () => { + test("output binary has valid executable header", async () => { + using dir = tempDir("build-compile-valid-header", { + "app.js": `console.log("hello");`, + }); + + const outfile = join(dir + "", "app-out"); + const result = await Bun.build({ + entrypoints: [join(dir + "", "app.js")], + compile: { + outfile, + }, + }); + + expect(result.success).toBe(true); + + // Read the first 4 bytes and verify it's a valid executable magic number + const file = Bun.file(result.outputs[0].path); + const header = new Uint8Array(await file.slice(0, 4).arrayBuffer()); + + if (isMacOS) { + // MachO magic: 0xCFFAEDFE (little-endian) + expect(header[0]).toBe(0xcf); + expect(header[1]).toBe(0xfa); + expect(header[2]).toBe(0xed); + expect(header[3]).toBe(0xfe); + } else if (isLinux) { + // ELF magic: 0x7F 'E' 'L' 'F' + expect(header[0]).toBe(0x7f); + expect(header[1]).toBe(0x45); // 'E' + expect(header[2]).toBe(0x4c); // 'L' + expect(header[3]).toBe(0x46); // 'F' + } else if (isWindows) { + // PE magic: 'M' 'Z' + expect(header[0]).toBe(0x4d); // 'M' + expect(header[1]).toBe(0x5a); // 'Z' + } + }); + + test("compiled binary runs and produces expected output", async () => { + using dir = tempDir("build-compile-runs", { + "app.js": `console.log("compile-test-output");`, + }); + + const outfile = join(dir + "", "app-run"); + const result = await Bun.build({ + entrypoints: [join(dir + "", "app.js")], + compile: { + outfile, + }, + }); + + expect(result.success).toBe(true); + + await using proc = Bun.spawn({ + cmd: [result.outputs[0].path], + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("compile-test-output"); + expect(exitCode).toBe(0); + }); +}); + // file command test works well