-
Notifications
You must be signed in to change notification settings - Fork 5.1k
fix(compile): use ELF section for standalone binaries on Linux #26923
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6f03fc5
a4024af
6df1908
c10a786
2726bf8
52c68b8
5268a5e
a34a584
77ca147
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -154,6 +154,22 @@ pub const StandaloneModuleGraph = struct { | |
| } | ||
| }; | ||
|
|
||
| const ELF = struct { | ||
| pub extern "C" fn Bun__getStandaloneModuleGraphELFVaddr() ?*align(1) u64; | ||
|
|
||
| pub fn getData() ?[]const u8 { | ||
| const vaddr = (Bun__getStandaloneModuleGraphELFVaddr() orelse return null).*; | ||
| if (vaddr == 0) return null; | ||
| // BUN_COMPILED.size holds the virtual address of the appended data. | ||
| // The kernel mapped it via PT_LOAD, so we can dereference directly. | ||
| // Format at target: [u64 payload_len][payload bytes] | ||
| const target: [*]const u8 = @ptrFromInt(vaddr); | ||
| const payload_len = std.mem.readInt(u64, target[0..8], .little); | ||
| if (payload_len < 8) return null; | ||
| return target[8..][0..payload_len]; | ||
| } | ||
| }; | ||
|
|
||
| pub const File = struct { | ||
| name: []const u8 = "", | ||
| loader: bun.options.Loader, | ||
|
|
@@ -885,6 +901,56 @@ pub const StandaloneModuleGraph = struct { | |
| } | ||
| return cloned_executable_fd; | ||
| }, | ||
| .linux => { | ||
| // ELF section approach: find .bun section and expand it | ||
| const input_result = bun.sys.File.readToEnd(.{ .handle = cloned_executable_fd }, bun.default_allocator); | ||
| if (input_result.err) |err| { | ||
| Output.prettyErrorln("Error reading executable: {f}", .{err}); | ||
| cleanup(zname, cloned_executable_fd); | ||
| return bun.invalid_fd; | ||
| } | ||
|
|
||
| const elf_file = bun.elf.ElfFile.init(bun.default_allocator, input_result.bytes.items) catch |err| { | ||
| Output.prettyErrorln("Error initializing ELF file: {}", .{err}); | ||
| cleanup(zname, cloned_executable_fd); | ||
| return bun.invalid_fd; | ||
| }; | ||
| defer elf_file.deinit(); | ||
|
|
||
| elf_file.writeBunSection(bytes) catch |err| { | ||
| Output.prettyErrorln("Error writing .bun section to ELF: {}", .{err}); | ||
| cleanup(zname, cloned_executable_fd); | ||
| return bun.invalid_fd; | ||
| }; | ||
| input_result.bytes.deinit(); | ||
|
|
||
| switch (Syscall.setFileOffset(cloned_executable_fd, 0)) { | ||
| .err => |err| { | ||
| Output.prettyErrorln("Error seeking to start of temporary file: {f}", .{err}); | ||
| cleanup(zname, cloned_executable_fd); | ||
| return bun.invalid_fd; | ||
| }, | ||
| else => {}, | ||
| } | ||
|
|
||
| // Write the modified ELF data back to the file | ||
| const write_file = bun.sys.File{ .handle = cloned_executable_fd }; | ||
| switch (write_file.writeAll(elf_file.data.items)) { | ||
| .err => |err| { | ||
| Output.prettyErrorln("Error writing ELF file: {f}", .{err}); | ||
| cleanup(zname, cloned_executable_fd); | ||
| return bun.invalid_fd; | ||
| }, | ||
| .result => {}, | ||
| } | ||
| // Truncate the file to the exact size of the modified ELF | ||
| _ = Syscall.ftruncate(cloned_executable_fd, @intCast(elf_file.data.items.len)); | ||
|
|
||
| if (comptime !Environment.isWindows) { | ||
| _ = bun.c.fchmod(cloned_executable_fd.native(), 0o777); | ||
| } | ||
| return cloned_executable_fd; | ||
|
Comment on lines
+904
to
+952
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find and examine the file
cd /root/repo && fd -t f "StandaloneModuleGraph.zig"Repository: oven-sh/bun Length of output: 115 🏁 Script executed: # Check the file size and read the relevant lines
wc -l /root/repo/src/StandaloneModuleGraph.zigRepository: oven-sh/bun Length of output: 126 🏁 Script executed: # Read lines around 904-952
sed -n '900,955p' /root/repo/src/StandaloneModuleGraph.zigRepository: oven-sh/bun Length of output: 138 🏁 Script executed: # Search for Syscall.ftruncate to understand its return type
rg "fn ftruncate|Syscall\.ftruncate" -A 3 -B 1Repository: oven-sh/bun Length of output: 3665 Handle The ftruncate result is currently ignored; if it fails, the output binary can be left in a partially updated state. Per the coding guidelines, Suggested fix- _ = Syscall.ftruncate(cloned_executable_fd, `@intCast`(elf_file.data.items.len));
+ switch (Syscall.ftruncate(cloned_executable_fd, `@intCast`(elf_file.data.items.len))) {
+ .err => |err| {
+ Output.prettyErrorln("Error truncating ELF file: {f}", .{err});
+ cleanup(zname, cloned_executable_fd);
+ return bun.invalid_fd;
+ },
+ .result => {},
+ }🤖 Prompt for AI Agents |
||
| }, | ||
|
Comment on lines
+904
to
+953
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Run zig:check-all for the new Linux ELF injection path. As per coding guidelines: When making platform-specific changes, run 🤖 Prompt for AI Agents |
||
| else => { | ||
| var total_byte_count: usize = undefined; | ||
| if (Environment.isWindows) { | ||
|
|
@@ -1261,99 +1327,23 @@ pub const StandaloneModuleGraph = struct { | |
| return try fromBytesAlloc(allocator, @constCast(pe_bytes), offsets); | ||
| } | ||
|
|
||
| // Do not invoke libuv here. | ||
| const self_exe = openSelf() catch return null; | ||
| defer self_exe.close(); | ||
|
|
||
| var trailer_bytes: [4096]u8 = undefined; | ||
| std.posix.lseek_END(self_exe.cast(), -4096) catch return null; | ||
|
|
||
| var read_amount: usize = 0; | ||
| while (read_amount < trailer_bytes.len) { | ||
| switch (Syscall.read(self_exe, trailer_bytes[read_amount..])) { | ||
| .result => |read| { | ||
| if (read == 0) return null; | ||
|
|
||
| read_amount += read; | ||
| }, | ||
| .err => { | ||
| return null; | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| if (read_amount < trailer.len + @sizeOf(usize) + @sizeOf(Offsets)) | ||
| // definitely missing data | ||
| return null; | ||
|
|
||
| var end = @as([]u8, &trailer_bytes).ptr + read_amount - @sizeOf(usize); | ||
| const total_byte_count: usize = @as(usize, @bitCast(end[0..8].*)); | ||
|
|
||
| if (total_byte_count > std.math.maxInt(u32) or total_byte_count < 4096) { | ||
| // sanity check: the total byte count should never be more than 4 GB | ||
| // bun is at least like 30 MB so if it reports a size less than 4096 bytes then something is wrong | ||
| return null; | ||
| } | ||
| end -= trailer.len; | ||
|
|
||
| if (!bun.strings.hasPrefixComptime(end[0..trailer.len], trailer)) { | ||
| // invalid trailer | ||
| return null; | ||
| } | ||
|
|
||
| end -= @sizeOf(Offsets); | ||
|
|
||
| const offsets: Offsets = std.mem.bytesAsValue(Offsets, end[0..@sizeOf(Offsets)]).*; | ||
| if (offsets.byte_count >= total_byte_count) { | ||
| // if we hit this branch then the file is corrupted and we should just give up | ||
| return null; | ||
| } | ||
|
|
||
| var to_read = try bun.default_allocator.alloc(u8, offsets.byte_count); | ||
| var to_read_from = to_read; | ||
|
|
||
| // Reading the data and making sure it's page-aligned + won't crash due | ||
| // to out of bounds using mmap() is very complicated. | ||
| // we just read the whole thing into memory for now. | ||
| // at the very least | ||
| // if you have not a ton of code, we only do a single read() call | ||
| if (Environment.allow_assert or offsets.byte_count > 1024 * 3) { | ||
| const offset_from_end = trailer_bytes.len - (@intFromPtr(end) - @intFromPtr(@as([]u8, &trailer_bytes).ptr)); | ||
| std.posix.lseek_END(self_exe.cast(), -@as(i64, @intCast(offset_from_end + offsets.byte_count))) catch return null; | ||
|
|
||
| if (comptime Environment.allow_assert) { | ||
| // actually we just want to verify this logic is correct in development | ||
| if (offsets.byte_count <= 1024 * 3) { | ||
| to_read_from = try bun.default_allocator.alloc(u8, offsets.byte_count); | ||
| } | ||
| } | ||
|
|
||
| var remain = to_read_from; | ||
| while (remain.len > 0) { | ||
| switch (Syscall.read(self_exe, remain)) { | ||
| .result => |read| { | ||
| if (read == 0) return null; | ||
|
|
||
| remain = remain[read..]; | ||
| }, | ||
| .err => { | ||
| bun.default_allocator.free(to_read); | ||
| return null; | ||
| }, | ||
| } | ||
| if (comptime Environment.isLinux) { | ||
| const elf_bytes = ELF.getData() orelse return null; | ||
| if (elf_bytes.len < @sizeOf(Offsets) + trailer.len) { | ||
| Output.debugWarn("bun standalone module graph is too small to be valid", .{}); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| if (offsets.byte_count <= 1024 * 3) { | ||
| // we already have the bytes | ||
| end -= offsets.byte_count; | ||
| @memcpy(to_read[0..offsets.byte_count], end[0..offsets.byte_count]); | ||
| if (comptime Environment.allow_assert) { | ||
| bun.assert(bun.strings.eqlLong(to_read, end[0..offsets.byte_count], true)); | ||
| const elf_bytes_slice = elf_bytes[elf_bytes.len - @sizeOf(Offsets) - trailer.len ..]; | ||
| const trailer_bytes = elf_bytes[elf_bytes.len - trailer.len ..][0..trailer.len]; | ||
| if (!bun.strings.eqlComptime(trailer_bytes, trailer)) { | ||
| Output.debugWarn("bun standalone module graph has invalid trailer", .{}); | ||
| return null; | ||
| } | ||
| const offsets = std.mem.bytesAsValue(Offsets, elf_bytes_slice).*; | ||
| return try fromBytesAlloc(allocator, @constCast(elf_bytes), offsets); | ||
| } | ||
|
|
||
| return try fromBytesAlloc(allocator, to_read, offsets); | ||
| comptime unreachable; | ||
| } | ||
|
|
||
| /// Allocates a StandaloneModuleGraph on the heap, populates it from bytes, sets it globally, and returns the pointer. | ||
|
|
@@ -1364,107 +1354,6 @@ pub const StandaloneModuleGraph = struct { | |
| return graph_ptr; | ||
| } | ||
|
|
||
| /// heuristic: `bun build --compile` won't be supported if the name is "bun", "bunx", or "node". | ||
| /// this is a cheap way to avoid the extra overhead of opening the executable, and also just makes sense. | ||
| fn isBuiltInExe(comptime T: type, argv0: []const T) bool { | ||
| if (argv0.len == 0) return false; | ||
|
|
||
| if (argv0.len == 3) { | ||
| if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun"), false)) { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| if (argv0.len == 4) { | ||
| if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bunx"), false)) { | ||
| return true; | ||
| } | ||
|
|
||
| if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "node"), false)) { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| if (comptime Environment.isDebug) { | ||
| if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun-debug"), true)) { | ||
| return true; | ||
| } | ||
| if (bun.strings.eqlComptimeCheckLenWithType(T, argv0, bun.strings.literal(T, "bun-debugx"), true)) { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| fn openSelf() std.fs.OpenSelfExeError!bun.FileDescriptor { | ||
| if (!Environment.isWindows) { | ||
| const argv = bun.argv; | ||
| if (argv.len > 0) { | ||
| if (isBuiltInExe(u8, argv[0])) { | ||
| return error.FileNotFound; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| switch (Environment.os) { | ||
| .linux => { | ||
| if (std.fs.openFileAbsoluteZ("/proc/self/exe", .{})) |easymode| { | ||
| return .fromStdFile(easymode); | ||
| } else |_| { | ||
| if (bun.argv.len > 0) { | ||
| // The user doesn't have /proc/ mounted, so now we just guess and hope for the best. | ||
| var whichbuf: bun.PathBuffer = undefined; | ||
| if (bun.which( | ||
| &whichbuf, | ||
| bun.env_var.PATH.get() orelse return error.FileNotFound, | ||
| "", | ||
| bun.argv[0], | ||
| )) |path| { | ||
| return .fromStdFile(try std.fs.cwd().openFileZ(path, .{})); | ||
| } | ||
| } | ||
|
|
||
| return error.FileNotFound; | ||
| } | ||
| }, | ||
| .mac => { | ||
| // Use of MAX_PATH_BYTES here is valid as the resulting path is immediately | ||
| // opened with no modification. | ||
| const self_exe_path = try bun.selfExePath(); | ||
| const file = try std.fs.openFileAbsoluteZ(self_exe_path.ptr, .{}); | ||
| return .fromStdFile(file); | ||
| }, | ||
| .windows => { | ||
| const image_path_unicode_string = std.os.windows.peb().ProcessParameters.ImagePathName; | ||
| const image_path = image_path_unicode_string.Buffer.?[0 .. image_path_unicode_string.Length / 2]; | ||
|
|
||
| var nt_path_buf: bun.WPathBuffer = undefined; | ||
| const nt_path = bun.strings.addNTPathPrefixIfNeeded(&nt_path_buf, image_path); | ||
|
|
||
| const basename_start = std.mem.lastIndexOfScalar(u16, nt_path, '\\') orelse | ||
| return error.FileNotFound; | ||
| const basename = nt_path[basename_start + 1 .. nt_path.len - ".exe".len]; | ||
| if (isBuiltInExe(u16, basename)) { | ||
| return error.FileNotFound; | ||
| } | ||
|
|
||
| return bun.sys.openFileAtWindows( | ||
| .cwd(), | ||
| nt_path, | ||
| .{ | ||
| .access_mask = w.SYNCHRONIZE | w.GENERIC_READ, | ||
| .disposition = w.FILE_OPEN, | ||
| .options = w.FILE_SYNCHRONOUS_IO_NONALERT | w.FILE_OPEN_REPARSE_POINT, | ||
| }, | ||
| ).unwrap() catch { | ||
| return error.FileNotFound; | ||
| }; | ||
| }, | ||
| .wasm => @compileError("TODO"), | ||
| } | ||
| } | ||
|
|
||
| /// Source map serialization in the bundler is specially designed to be | ||
| /// loaded in memory as is. Source contents are compressed with ZSTD to | ||
| /// reduce the file size, and mappings are stored as uncompressed VLQ. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3763,6 +3763,7 @@ pub fn freeSensitive(allocator: std.mem.Allocator, slice: anytype) void { | |
|
|
||
| pub const macho = @import("./macho.zig"); | ||
| pub const pe = @import("./pe.zig"); | ||
| pub const elf = @import("./elf.zig"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move the new As per coding guidelines: Place 🤖 Prompt for AI Agents |
||
| pub const valkey = @import("./valkey/index.zig"); | ||
| pub const highway = @import("./highway.zig"); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣
input_result.bytesis leaked ifElfFile.init(line 913) orwriteBunSection(line 920) fails, since.deinit()on line 925 is only reached on success. This is a pre-existing pattern — the macOS path (lines 820-831) and Windows path (lines 862-880) have the identical leak. Addingdefer input_result.bytes.deinit()after line 911 would fix all error paths here.Extended reasoning...
Memory leak of
input_result.byteson error pathsIn the new Linux ELF injection path,
input_result.bytesis allocated bybun.sys.File.readToEnd()on line 906 and holds the entire bun executable (typically ~100MB). The.deinit()call that frees this buffer is on line 925, which is only reached when bothElfFile.initandwriteBunSectionsucceed.Concrete error path walkthrough
input_result = bun.sys.File.readToEnd(...)succeeds, allocating ~100MB ininput_result.bytesbun.elf.ElfFile.init(...)is called withinput_result.bytes.itemsinitfails (e.g., invalid ELF magic, not 64-bit, not little-endian), thecatchblock on lines 914-916 executescleanup(zname, cloned_executable_fd)and returnsbun.invalid_fdinput_result.bytes.deinit()on line 925 is never reached — the ~100MB buffer is leakedThe same leak occurs if
writeBunSectionfails on line 920: the catch block on lines 921-923 returns early before line 925.Pre-existing pattern
This is not unique to the Linux path introduced in this PR. The macOS path (lines 820-831) has the identical structure:
MachoFile.initorwriteSectionfailing causes an early return that skipsinput_result.bytes.deinit(). The Windows path (lines 862-880) follows the same pattern. The new Linux code faithfully copies this established convention.Impact
The practical impact is low. These error paths only trigger during
bun build --compilewhen the executable is malformed or when ELF manipulation fails. In these cases, the build fails and the process exits shortly after, at which point the OS reclaims all memory. The leak is at most ~100MB and occurs at most once per process.Fix
The cleanest fix for all three platforms would be to add
defer input_result.bytes.deinit()immediately after the error check on line 911 (and equivalently in the macOS/Windows paths). SinceElfFile.initcopies the data into its own managed buffer, the originalinput_result.bytescan safely be freed at any point afterinitsucceeds. Usingdeferwould ensure it is freed on all paths — success and error alike.