Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 79 additions & 190 deletions src/StandaloneModuleGraph.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Comment on lines 903 to +925

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 input_result.bytes is leaked if ElfFile.init (line 913) or writeBunSection (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. Adding defer input_result.bytes.deinit() after line 911 would fix all error paths here.

Extended reasoning...

Memory leak of input_result.bytes on error paths

In the new Linux ELF injection path, input_result.bytes is allocated by bun.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 both ElfFile.init and writeBunSection succeed.

Concrete error path walkthrough

  1. Line 906: input_result = bun.sys.File.readToEnd(...) succeeds, allocating ~100MB in input_result.bytes
  2. Line 913: bun.elf.ElfFile.init(...) is called with input_result.bytes.items
  3. If init fails (e.g., invalid ELF magic, not 64-bit, not little-endian), the catch block on lines 914-916 executes
  4. The catch block calls cleanup(zname, cloned_executable_fd) and returns bun.invalid_fd
  5. input_result.bytes.deinit() on line 925 is never reached — the ~100MB buffer is leaked

The same leak occurs if writeBunSection fails 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.init or writeSection failing causes an early return that skips input_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 --compile when 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). Since ElfFile.init copies the data into its own managed buffer, the original input_result.bytes can safely be freed at any point after init succeeds. Using defer would ensure it is freed on all paths — success and error alike.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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.zig

Repository: oven-sh/bun

Length of output: 126


🏁 Script executed:

# Read lines around 904-952
sed -n '900,955p' /root/repo/src/StandaloneModuleGraph.zig

Repository: 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 1

Repository: oven-sh/bun

Length of output: 3665


Handle Syscall.ftruncate failures.

The ftruncate result is currently ignored; if it fails, the output binary can be left in a partially updated state. Per the coding guidelines, Maybe(void) results must be handled with switch statements or .unwrap().

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
Verify each finding against the current code and only fix it if needed.

In `@src/StandaloneModuleGraph.zig` around lines 904 - 952, The Syscall.ftruncate
call at the end of the ELF write block is currently ignored; change it to handle
the Maybe(void) result (from Syscall.ftruncate(cloned_executable_fd,
`@intCast`(elf_file.data.items.len))) using a switch or .catch to detect errors,
log a descriptive error via Output.prettyErrorln (including the error value),
call cleanup(zname, cloned_executable_fd), and return bun.invalid_fd on failure
so the temporary file is not left in a partially-updated state; keep the
successful path unchanged and preserve the existing permission-setting and
return of cloned_executable_fd.

},
Comment on lines +904 to +953

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.
Please run bun run zig:check-all to validate cross-platform Zig builds after this platform-specific change.

As per coding guidelines: When making platform-specific changes, run bun run zig:check-all to compile the Zig code on all platforms.

🤖 Prompt for AI Agents
In `@src/StandaloneModuleGraph.zig` around lines 904 - 953, You added a
Linux-specific ELF injection path in StandaloneModuleGraph.zig (the .linux
branch that uses bun.elf.ElfFile, elf_file.writeBunSection,
Syscall.setFileOffset, Syscall.ftruncate and bun.c.fchmod); run the full
cross-platform Zig build check by executing `bun run zig:check-all`, fix any
compile or platform-conditional issues reported (e.g., missing imports, comptime
guards around Environment.isWindows usage, incorrect types or unreachable
branches), and iterate until `zig:check-all` completes cleanly so the new Linux
code compiles on all target platforms.

else => {
var total_byte_count: usize = undefined;
if (Environment.isWindows) {
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/bun.js/bindings/c-bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,10 @@ extern "C" void Bun__signpost_emit(os_log_t log, os_signpost_type_t type, os_sig
#undef EMIT_SIGNPOST
#undef FOR_EACH_TRACE_EVENT

#endif // OS(DARWIN) signpost code

#if OS(DARWIN) || defined(__linux__)

#define BLOB_HEADER_ALIGNMENT 16 * 1024

extern "C" {
Expand All @@ -919,13 +923,26 @@ struct BlobHeader {
} __attribute__((aligned(BLOB_HEADER_ALIGNMENT)));
}

#if OS(DARWIN)

extern "C" BlobHeader __attribute__((section("__BUN,__bun"))) BUN_COMPILED = { 0, 0 };

extern "C" uint64_t* Bun__getStandaloneModuleGraphMachoLength()
{
return &BUN_COMPILED.size;
}

#else // __linux__

extern "C" BlobHeader __attribute__((section(".bun"), aligned(BLOB_HEADER_ALIGNMENT), used)) BUN_COMPILED = { 0 };

extern "C" uint64_t* Bun__getStandaloneModuleGraphELFVaddr()
{
return &BUN_COMPILED.size;
}

#endif // OS(DARWIN) / __linux__

#elif defined(_WIN32)
// Windows PE section handling
#include <windows.h>
Expand Down
1 change: 1 addition & 0 deletions src/bun.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Move the new @import into the bottom import block.
This keeps imports consolidated per the repository’s Zig conventions.

As per coding guidelines: Place @import statements at the bottom of the file in Zig (auto formatter will handle positioning).

🤖 Prompt for AI Agents
In `@src/bun.zig` at line 3707, The new top-level import "pub const elf =
`@import`(\"./elf.zig\");" was added near line 3707; move this `@import` into the
file's existing bottom import block so all `@import` statements are consolidated
per repository Zig conventions—locate the declaration "pub const elf" and cut it
from its current spot and paste it into the module's import section at the end
of the file (preserve the pub const name and string path exactly so references
to elf continue to work).

pub const valkey = @import("./valkey/index.zig");
pub const highway = @import("./highway.zig");

Expand Down
Loading
Loading