Skip to content

fix(compile): use ELF section for standalone binaries on Linux - #26923

Merged
Jarred-Sumner merged 9 commits into
mainfrom
claude/elf-section-standalone
Mar 20, 2026
Merged

Jarred-Sumner merged 9 commits into
mainfrom
claude/elf-section-standalone

Conversation

@dylan-conway

Copy link
Copy Markdown
Member

Summary

  • Standalone executables on Linux previously read their embedded module graph from /proc/self/exe at startup, which fails when the binary has execute-only permissions (chmod 111)
  • Now uses an ELF section approach (.bun section with BUN_COMPILED linker symbol), matching the existing macOS (__BUN,__bun) and Windows (.bun PE section) implementations
  • At runtime, the kernel maps the data via PT_LOAD during execve — zero file I/O, no read permission needed

How it works

Build time (src/elf.zig): Appends the module graph to the end of the ELF file, converts PT_GNU_STACK into a PT_LOAD segment to map it, and stores the new virtual address at the original BUN_COMPILED location.

Runtime (StandaloneModuleGraph.zig): BUN_COMPILED.size holds either 0 (not standalone) or the vaddr of the appended data. Just dereferences a pointer to get the module graph.

Files changed

File Change
src/elf.zig New — ELF manipulation module (229 lines)
src/bun.zig Added elf import
src/bun.js/bindings/c-bindings.cpp Added Linux BUN_COMPILED in .bun section
src/StandaloneModuleGraph.zig Added ELF struct, Linux inject/read paths, removed /proc/self/exe reading
test/bundler/bun-build-compile.test.ts 4 new Linux-only tests

Test plan

  • bun bd test test/bundler/bun-build-compile.test.ts — 10/10 pass
  • bun bd test test/bundler/bun-build-compile-sourcemap.test.ts — 5/5 pass
  • bun bd test test/bundler/compile-argv.test.ts — 10/10 pass
  • Manual: small payload + chmod 111 — works
  • Manual: large payload (>16KB, forces append path) + chmod 111 — works

🤖 Generated with Claude Code

@claude

claude Bot commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude is monitoring this PR

I'll review code, respond to comments, and investigate CI failures.
Start any comment with (skip) to have me ignore it.

  • Push fix commits (uncheck to only post review comments)

@robobun

robobun commented Feb 12, 2026

Copy link
Copy Markdown
Collaborator
Updated 10:42 PM PT - Mar 19th, 2026

@Jarred-Sumner, your commit 77ca147 has 1 failures in Build #40193 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 26923

That installs a local version of the PR into your bun-26923 executable, so you can run:

bun-26923 --bun

@coderabbitai

coderabbitai Bot commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds ELF support for embedding/extracting the standalone module graph: new elf module and ElfFile API, public ELF accessor in StandaloneModuleGraph, C bindings exposing Mach-O/ELF/PE blob hooks, Linux ELF-targeted tests, and small test-config updates.

Changes

Cohort / File(s) Summary
ELF implementation
src/elf.zig
New public ElfFile type and APIs: validate ELF, locate .bun section, append payload with a 64-bit size header, update section/program headers (repurpose GNU_STACK -> PT_LOAD), and write modified ELF.
Standalone module graph runtime
src/StandaloneModuleGraph.zig
Added public ELF declaration exposing pub extern "C" fn Bun__getStandaloneModuleGraphELFVaddr() and pub fn getData(); Linux loading now uses ELF.getData() with length/offset validation, removing prior self-exe open logic.
C bindings / platform blobs
src/bun.js/bindings/c-bindings.cpp
Adds platform-specific BUN_COMPILED definitions and accessors: Mach-O __BUN,__bun length hook, ELF .bun size vaddr hook, and Windows PE helpers (Bun__getStandaloneModuleGraphPELength, Bun__getStandaloneModuleGraphPEData) plus PE section discovery/mapping state. Also adjusts an #endif signpost placement.
Public API surface
src/bun.zig
Exports new ELF module: pub const elf = @import("./elf.zig");.
Tests and config
test/bundler/bun-build-compile.test.ts, test/internal/ban-limits.json
Adds Linux-only ELF tests (execute-only binaries, large payloads, and .bun section presence) and updates two frequency counts in test/internal/ban-limits.json (std.fs.cwd −1, std.fs.openFileAbsolute −2).
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: fixing standalone binary compilation on Linux by using ELF section approach instead of /proc/self/exe reading.
Description check ✅ Passed The description comprehensively covers the problem, solution, implementation details, and test plan, exceeding the basic template requirements with structured explanations and verification evidence.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/bun.zig`:
- 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).

In `@src/elf.zig`:
- Around line 12-35: The Managed buffer created as "data" isn't freed if later
steps fail (e.g., allocator.create), so add an errdefer to release it: after
creating and populating data (the variable from
std.array_list.Managed(u8).initCapacity and try data.appendSlice), add "errdefer
data.deinit();" before calling allocator.create(ElfFile) so the buffer is
cleaned on any error; keep the existing errdefer allocator.destroy(self) for the
created ElfFile and rely on errdefer semantics (runs only on error) so
successful init transfers ownership into self.* without leaking.
- Around line 53-121: The writeBunSection function is using u64 values as slice
indices and for buffer capacity calls which require usize; fix by casting the
named u64 values to usize where they are used as slice indices or passed to
ensureTotalCapacity: convert new_file_offset, new_shdr_offset, shdr_table_size,
total_new_size (argument to self.data.ensureTotalCapacity), padding_start, and
bun_section_offset to usize before any slice like self.data.items[...],
memmove/memcpy/memset ranges, and before calling std.mem.writeInt or other
functions that take slice ranges; keep the variables as u64 for arithmetic but
create usize locals (e.g., new_file_offset_usize) and use those in memmove,
`@memcpy`, `@memset`, self.data.items.len assignments, and the ensureTotalCapacity
call in writeBunSection.

In `@src/StandaloneModuleGraph.zig`:
- Around line 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.

In `@test/bundler/bun-build-compile.test.ts`:
- Around line 208-209: Replace the dynamic require of chmodSync with a
module-level import: add an import (or const { chmodSync } = require("fs")) at
the top of the test module and remove the inline require calls currently present
where chmodSync is used (references in the test near usages around
result.outputs[0].path and the later occurrence at the other use site). Update
the test to call the module-scoped chmodSync directly so no runtime require
remains and ensure both occurrences (the one shown and the other later call) are
deleted.

Comment thread src/bun.zig

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).

Comment thread src/elf.zig
Comment thread src/elf.zig
Comment on lines +53 to +121
pub fn writeBunSection(self: *ElfFile, payload: []const u8) !void {
const ehdr = readEhdr(self.data.items);
const bun_section_offset = try self.findBunSection(ehdr);
const page_size = pageSize(ehdr);

const header_size: u64 = @sizeOf(u64);
const new_content_size: u64 = header_size + payload.len;
const aligned_new_size = alignUp(new_content_size, page_size);

// Find the highest virtual address across all PT_LOAD segments
var max_vaddr_end: u64 = 0;
const phdr_size = @sizeOf(Elf64_Phdr);
for (0..ehdr.e_phnum) |i| {
const phdr_offset = @as(usize, @intCast(ehdr.e_phoff)) + i * phdr_size;
const phdr = std.mem.bytesAsValue(Elf64_Phdr, self.data.items[phdr_offset..][0..phdr_size]).*;
if (phdr.p_type == elf.PT_LOAD) {
const vaddr_end = phdr.p_vaddr + phdr.p_memsz;
if (vaddr_end > max_vaddr_end) {
max_vaddr_end = vaddr_end;
}
}
}

// The new segment's virtual address: after all existing mappings, page-aligned
const new_vaddr = alignUp(max_vaddr_end, page_size);

// The new data goes at the end of the file, page-aligned
const new_file_offset = alignUp(self.data.items.len, page_size);

// Grow the buffer to hold the new data + section header table after it
const shdr_table_size = @as(u64, ehdr.e_shnum) * @sizeOf(Elf64_Shdr);
const new_shdr_offset = new_file_offset + aligned_new_size;
const total_new_size = new_shdr_offset + shdr_table_size;

const old_file_size = self.data.items.len;
try self.data.ensureTotalCapacity(total_new_size);
self.data.items.len = total_new_size;

// Zero the gap between old file end and new data (alignment padding).
// Without this, uninitialized allocator memory would leak into the output.
if (new_file_offset > old_file_size) {
@memset(self.data.items[old_file_size..new_file_offset], 0);
}

// Copy the section header table to its new location
const old_shdr_offset = ehdr.e_shoff;
bun.memmove(
self.data.items[new_shdr_offset..][0..shdr_table_size],
self.data.items[old_shdr_offset..][0..shdr_table_size],
);

// Update e_shoff to the new section header table location
self.writeEhdrShoff(new_shdr_offset);

// Write the payload at the new location: [u64 LE size][data][zero padding]
std.mem.writeInt(u64, self.data.items[new_file_offset..][0..8], @intCast(payload.len), .little);
@memcpy(self.data.items[new_file_offset + header_size ..][0..payload.len], payload);

// Zero the padding between payload end and section header table
const padding_start = new_file_offset + new_content_size;
if (new_shdr_offset > padding_start) {
@memset(self.data.items[padding_start..new_shdr_offset], 0);
}

// Write the vaddr of the appended data at the ORIGINAL .bun section location
// (where BUN_COMPILED symbol points). At runtime, BUN_COMPILED.size will be
// this vaddr (always non-zero), which the runtime dereferences as a pointer.
// Non-standalone binaries have BUN_COMPILED.size = 0, so 0 means "no data".
std.mem.writeInt(u64, self.data.items[bun_section_offset..][0..8], new_vaddr, .little);

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and read the relevant section
wc -l src/elf.zig

Repository: oven-sh/bun

Length of output: 70


🏁 Script executed:

# Read the section mentioned in the review (lines 53-121)
sed -n '53,121p' src/elf.zig

Repository: oven-sh/bun

Length of output: 3512


🏁 Script executed:

# Also get some context around this function to understand the full signature
sed -n '45,130p' src/elf.zig

Repository: oven-sh/bun

Length of output: 4555


🏁 Script executed:

# Find alignUp and pageSize function definitions
rg -A 5 "fn alignUp|fn pageSize" src/elf.zig

Repository: oven-sh/bun

Length of output: 392


🏁 Script executed:

# Check what types alignUp returns and how it's used elsewhere
rg "alignUp" src/elf.zig | head -20

Repository: oven-sh/bun

Length of output: 304


🏁 Script executed:

# Check the ElfFile struct to understand self.data type
rg -B 5 -A 10 "struct ElfFile|pub const ElfFile" src/elf.zig | head -40

Repository: oven-sh/bun

Length of output: 747


🏁 Script executed:

# Check the Managed type to confirm ensureTotalCapacity signature
rg -A 3 "pub fn ensureTotalCapacity" src/elf.zig

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Get the complete picture - check what happens around lines 107-114 in context
sed -n '105,120p' src/elf.zig

Repository: oven-sh/bun

Length of output: 996


🏁 Script executed:

# Let's verify by checking if there are similar patterns in the codebase that handle this correctly
rg -B 2 -A 2 "ensureTotalCapacity.*@intCast|\.items\.len.*@intCast" src/ | head -30

Repository: oven-sh/bun

Length of output: 1825


🏁 Script executed:

# Check if the file compiles - let's look for any build configuration
ls -la src/elf.zig && head -30 src/elf.zig

Repository: oven-sh/bun

Length of output: 1370


🏁 Script executed:

# Let's verify the actual type issue by checking what Zig std.ArrayList.Managed expects
# Check how similar operations are handled elsewhere in the codebase
rg "\.items\.len.*alignUp|alignUp.*\.items\.len" src/ -A 2 -B 2

Repository: oven-sh/bun

Length of output: 335


🏁 Script executed:

# Check if there are other places where u64 values are used directly as slice indices
rg "@memset.*\.\.\|@memcpy.*\.\." src/elf.zig

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Let's verify the actual Zig compilation error by checking nearby working patterns
sed -n '65,72p' src/elf.zig

Repository: oven-sh/bun

Length of output: 504


🏁 Script executed:

# Check the actual signature of ensureTotalCapacity by looking at standard library usage patterns
rg -B 2 -A 2 'ensureTotalCapacity\(' src/elf.zig src/macho.zig | head -40

Repository: oven-sh/bun

Length of output: 272


🏁 Script executed:

# Check if there's a recent commit that might have introduced this code
git log --oneline src/elf.zig | head -5

Repository: oven-sh/bun

Length of output: 98


🏁 Script executed:

# Let's check if Zig has implicit coercion between u64 and usize for alignUp call
# by searching for similar patterns in the codebase
rg "alignUp\(" src/ -B 1 -A 1 | grep -E "alignUp|items\.len|u64" | head -30

Repository: oven-sh/bun

Length of output: 631


🏁 Script executed:

# Most importantly, let's verify by checking if this file has compilation issues
# by looking at the build.zig or checking for error reports
git log --all --grep="elf.zig" --oneline | head -5

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Let's check what the current state of the repo is - are there any compilation issues?
# Check if there are open issues or recent PRs related to elf.zig
git log --all --oneline src/elf.zig | head -10

Repository: oven-sh/bun

Length of output: 98


🏁 Script executed:

# Most importantly, let's verify the exact type issue by checking slice operations
# In Zig, slice indices MUST be usize. Let's see if the code has actual type errors
# by examining what happens when u64 is used as a slice index

# Line 93: `@memset`(self.data.items[old_file_size..new_file_offset], 0)
# where old_file_size is usize but new_file_offset is u64

# Let's trace through the types more carefully
sed -n '85,100p' src/elf.zig | cat -n

Repository: oven-sh/bun

Length of output: 880


🏁 Script executed:

# Check if implicit coercion happens or if there's a compilation error
# by looking at the actual types at each line
python3 << 'EOF'
code = """
const header_size: u64 = `@sizeOf`(u64);
const new_content_size: u64 = header_size + payload.len;  // payload.len is usize
const aligned_new_size = alignUp(new_content_size, page_size);  // alignUp returns u64
const new_file_offset = alignUp(self.data.items.len, page_size);  // self.data.items.len is usize passed to u64 param, result is u64
const shdr_table_size = `@as`(u64, ehdr.e_shnum) * `@sizeOf`(Elf64_Shdr);  // explicitly u64
const new_shdr_offset = new_file_offset + aligned_new_size;  // u64 + u64 = u64
const total_new_size = new_shdr_offset + shdr_table_size;  // u64 + u64 = u64
const old_file_size = self.data.items.len;  // usize
try self.data.ensureTotalCapacity(total_new_size);  // passing u64 where usize expected - ERROR
self.data.items.len = total_new_size;  // assigning u64 to usize - ERROR
`@memset`(self.data.items[old_file_size..new_file_offset], 0);  // slice indices must be usize, new_file_offset is u64 - ERROR
"""

lines = code.split('\n')
for i, line in enumerate(lines, 1):
    if 'ERROR' in line:
        print(f"Line {i}: {line.strip()}")
EOF

Repository: oven-sh/bun

Length of output: 375


🏁 Script executed:

# Verify the exact error by checking if the code compiles
# Let's see if there are recent CI failures or if this is a known issue
git log --all --oneline --grep="type" -- src/elf.zig | head -10

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Check the actual recent history to see if this code is new or if it's been fixed
git show b9ac0a5b:src/elf.zig 2>/dev/null | sed -n '85,100p' || echo "Commit may not have this file"

Repository: oven-sh/bun

Length of output: 768


🏁 Script executed:

# Let's verify what the actual compilation behavior would be by checking Zig's type coercion rules
# Zig does NOT implicitly coerce u64 to usize in most contexts, especially for slice indices
# This is a real error. Let's see if there's a way the code could compile

# Check if there's any wrapper function that might be accepting u64
rg "fn ensureTotalCapacity" --type zig | head -5

Repository: oven-sh/bun

Length of output: 581


🏁 Script executed:

# Let's verify the Managed type definition for ArrayList
rg -B 5 -A 10 "pub const Managed" src/collections/array_list.zig | head -30

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Let's also check if the code we're looking at is actually in a compiled path
# by verifying if this function is actually called/used
rg "writeBunSection" src/ --type zig

Repository: oven-sh/bun

Length of output: 230


🏁 Script executed:

# Check if this code is in a conditional compilation block or if it's newly added
git log -p --all -- src/elf.zig | head -100

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Let's verify if the code is actually being compiled by checking build configuration
find . -name "build.zig" -o -name "build.zig.zon" | head -5

Repository: oven-sh/bun

Length of output: 126


🏁 Script executed:

# Check if there's a way to actually compile and test this
rg "elf_file|ElfFile" src/StandaloneModuleGraph.zig -B 5 -A 5 | head -40

Repository: oven-sh/bun

Length of output: 1880


🏁 Script executed:

# Verify the exact line numbers and check if there's a recent fix or if this is still broken
git diff HEAD src/elf.zig | head -50

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Check the current status - does the code compile without errors?
# by checking if there are any recent fixes
git log --oneline -20 src/elf.zig

Repository: oven-sh/bun

Length of output: 98


🏁 Script executed:

# Let's verify one more thing - check if there are other slice operations using u64 values
sed -n '105,120p' src/elf.zig

Repository: oven-sh/bun

Length of output: 996


🏁 Script executed:

# Let's do a final comprehensive check on the actual slice operations with u64 indices
python3 << 'EOF'
import re

code = """    pub fn writeBunSection(self: *ElfFile, payload: []const u8) !void {
        const ehdr = readEhdr(self.data.items);
        const bun_section_offset = try self.findBunSection(ehdr);
        const page_size = pageSize(ehdr);

        const header_size: u64 = `@sizeOf`(u64);
        const new_content_size: u64 = header_size + payload.len;
        const aligned_new_size = alignUp(new_content_size, page_size);

        // Find the highest virtual address across all PT_LOAD segments
        var max_vaddr_end: u64 = 0;
        const phdr_size = `@sizeOf`(Elf64_Phdr);
        for (0..ehdr.e_phnum) |i| {
            const phdr_offset = `@as`(usize, `@intCast`(ehdr.e_phoff)) + i * phdr_size;
            const phdr = std.mem.bytesAsValue(Elf64_Phdr, self.data.items[phdr_offset..][0..phdr_size]).*;
            if (phdr.p_type == elf.PT_LOAD) {
                const vaddr_end = phdr.p_vaddr + phdr.p_memsz;
                if (vaddr_end > max_vaddr_end) {
                    max_vaddr_end = vaddr_end;
                }
            }
        }

        // The new segment's virtual address: after all existing mappings, page-aligned
        const new_vaddr = alignUp(max_vaddr_end, page_size);

        // The new data goes at the end of the file, page-aligned
        const new_file_offset = alignUp(self.data.items.len, page_size);

        // Grow the buffer to hold the new data + section header table after it
        const shdr_table_size = `@as`(u64, ehdr.e_shnum) * `@sizeOf`(Elf64_Shdr);
        const new_shdr_offset = new_file_offset + aligned_new_size;
        const total_new_size = new_shdr_offset + shdr_table_size;

        const old_file_size = self.data.items.len;
        try self.data.ensureTotalCapacity(total_new_size);
        self.data.items.len = total_new_size;

        // Zero the gap between old file end and new data (alignment padding).
        // Without this, uninitialized allocator memory would leak into the output.
        if (new_file_offset > old_file_size) {
            `@memset`(self.data.items[old_file_size..new_file_offset], 0);
        }

        // Copy the section header table to its new location
        const old_shdr_offset = ehdr.e_shoff;
        bun.memmove(
            self.data.items[new_shdr_offset..][0..shdr_table_size],
            self.data.items[old_shdr_offset..][0..shdr_table_size],
        );

        // Update e_shoff to the new section header table location
        self.writeEhdrShoff(new_shdr_offset);

        // Write the payload at the new location: [u64 LE size][data][zero padding]
        std.mem.writeInt(u64, self.data.items[new_file_offset..][0..8], `@intCast`(payload.len), .little);
        `@memcpy`(self.data.items[new_file_offset + header_size ..][0..payload.len], payload);

        // Zero the padding between payload end and section header table
        const padding_start = new_file_offset + new_content_size;
        if (new_shdr_offset > padding_start) {
            `@memset`(self.data.items[padding_start..new_shdr_offset], 0);
        }

        // Write the vaddr of the appended data at the ORIGINAL .bun section location
        // (where BUN_COMPILED symbol points). At runtime, BUN_COMPILED.size will be
        // this vaddr (always non-zero), which the runtime dereferences as a pointer.
        // Non-standalone binaries have BUN_COMPILED.size = 0, so 0 means "no data".
        std.mem.writeInt(u64, self.data.items[bun_section_offset..][0..8], new_vaddr, .little);
    }"""

# Find all slice operations
slice_pattern = r'\.items\[([^\]]+)\.\.\]'
matches = re.finditer(slice_pattern, code)

print("Slice operations with potential u64 indices:")
for match in matches:
    expr = match.group(1)
    print(f"  - .items[{expr}..] - needs verification")

# Type tracking
print("\nType assignments:")
assignments = [
    ("new_file_offset", "alignUp(self.data.items.len, page_size) → u64"),
    ("new_shdr_offset", "new_file_offset + aligned_new_size → u64"),
    ("shdr_table_size", "@as(u64, ...) → u64"),
    ("total_new_size", "new_shdr_offset + shdr_table_size → u64"),
    ("padding_start", "new_file_offset + new_content_size → u64"),
]
for var, typ in assignments:
    print(f"  - {var}: {typ}")
EOF

Repository: oven-sh/bun

Length of output: 728


🏁 Script executed:

# Final verification - check if this code was recently added and hasn't been compiled yet
# or if it's in a Linux-only path
rg "linux|linux_x64|aarch64" src/elf.zig

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Check if the code is wrapped in a condition that might not be compiled by default
head -50 src/elf.zig | grep -E "comptime|if.*Environment"

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Most importantly - verify the actual compilation status by looking at CI config
find . -name ".github" -o -name ".cirrus.yml" -o -name "Makefile" | head -5

Repository: oven-sh/bun

Length of output: 107


🏁 Script executed:

# Let me check the GitHub workflows to understand compilation
ls -la .github/workflows/ 2>/dev/null | head -20

Repository: oven-sh/bun

Length of output: 1330


🏁 Script executed:

# Verify if there are any known Zig compiler compatibility issues with this code
rg "error|compile|fail" .github/ 2>/dev/null | grep -i elf | head -10

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Final check - verify the exact types involved by checking if there are type annotations nearby
sed -n '86,92p' src/elf.zig | cat -n

Repository: oven-sh/bun

Length of output: 424


🏁 Script executed:

# Let me check the findBunSection return type to complete the picture
rg -A 10 "fn findBunSection" src/elf.zig

Repository: oven-sh/bun

Length of output: 577


🏁 Script executed:

# Also verify the exact behavior of Zig type coercion with this specific pattern
# Check if there are any recent changes or PRs related to this code
git log --all --since="2026-01-01" --oneline -- src/elf.zig

Repository: oven-sh/bun

Length of output: 98


🏁 Script executed:

# Verify that the code would fail to compile by checking Zig's type system
# Let me also check if there are any implicit coercion rules that might apply
python3 << 'EOF'
# In Zig, slice indices MUST be usize
# The type system is strict about this - there is NO implicit coercion from u64 to usize
# This is a safety feature to prevent out-of-bounds access

# Let's document the errors:
errors = {
    "Line 87": "ensureTotalCapacity(total_new_size: u64) expects usize",
    "Line 88": "items.len = total_new_size tries to assign u64 to usize",
    "Line 92": "@memset(.items[old_file_size..new_file_offset], 0) - new_file_offset is u64",
    "Line 99": ".items[new_shdr_offset..][0..shdr_table_size] - new_shdr_offset is u64",
    "Line 108": ".items[new_file_offset..][0..8] - new_file_offset is u64",
    "Line 109": ".items[new_file_offset + header_size ..] - index is u64",
    "Line 113": "@memset(.items[padding_start..new_shdr_offset]) - both are u64",
    "Line 119": ".items[bun_section_offset..][0..8] - bun_section_offset is u64",
}

print("Expected compilation errors:")
for line, err in errors.items():
    print(f"  {line}: {err}")
EOF

Repository: oven-sh/bun

Length of output: 672


Fix u64/usize type mismatches in slice operations and buffer capacity calls.

Zig requires usize for all slice indices and buffer capacity operations. Multiple slice operations use u64 values directly (lines 87–88, 92, 99, 108–109, 113, 119), which will fail to compile. Cast new_file_offset, new_shdr_offset, shdr_table_size, total_new_size, padding_start, and bun_section_offset to usize before using them as slice indices or in ensureTotalCapacity().

Suggested fix
+        const total_new_size_usize: usize = `@intCast`(total_new_size);
+        const new_file_offset_usize: usize = `@intCast`(new_file_offset);
+        const new_shdr_offset_usize: usize = `@intCast`(new_shdr_offset);
+        const shdr_table_size_usize: usize = `@intCast`(shdr_table_size);
+
         const old_file_size = self.data.items.len;
-        try self.data.ensureTotalCapacity(total_new_size);
-        self.data.items.len = total_new_size;
+        try self.data.ensureTotalCapacity(total_new_size_usize);
+        self.data.items.len = total_new_size_usize;

         if (new_file_offset > old_file_size) {
-            `@memset`(self.data.items[old_file_size..new_file_offset], 0);
+            `@memset`(self.data.items[old_file_size..new_file_offset_usize], 0);
         }

-            self.data.items[new_shdr_offset..][0..shdr_table_size],
-            self.data.items[old_shdr_offset..][0..shdr_table_size],
+            self.data.items[new_shdr_offset_usize..][0..shdr_table_size_usize],
+            self.data.items[old_shdr_offset..][0..shdr_table_size_usize],

-        std.mem.writeInt(u64, self.data.items[new_file_offset..][0..8], `@intCast`(payload.len), .little);
-        `@memcpy`(self.data.items[new_file_offset + header_size ..][0..payload.len], payload);
+        std.mem.writeInt(u64, self.data.items[new_file_offset_usize..][0..8], `@intCast`(payload.len), .little);
+        `@memcpy`(self.data.items[new_file_offset_usize + header_size ..][0..payload.len], payload);

+        const padding_start_usize: usize = `@intCast`(padding_start);
         if (new_shdr_offset > padding_start) {
-            `@memset`(self.data.items[padding_start..new_shdr_offset], 0);
+            `@memset`(self.data.items[padding_start_usize..new_shdr_offset_usize], 0);
         }

-        std.mem.writeInt(u64, self.data.items[bun_section_offset..][0..8], new_vaddr, .little);
+        const bun_section_offset_usize: usize = `@intCast`(bun_section_offset);
+        std.mem.writeInt(u64, self.data.items[bun_section_offset_usize..][0..8], new_vaddr, .little);
🤖 Prompt for AI Agents
In `@src/elf.zig` around lines 53 - 121, The writeBunSection function is using u64
values as slice indices and for buffer capacity calls which require usize; fix
by casting the named u64 values to usize where they are used as slice indices or
passed to ensureTotalCapacity: convert new_file_offset, new_shdr_offset,
shdr_table_size, total_new_size (argument to self.data.ensureTotalCapacity),
padding_start, and bun_section_offset to usize before any slice like
self.data.items[...], memmove/memcpy/memset ranges, and before calling
std.mem.writeInt or other functions that take slice ranges; keep the variables
as u64 for arithmetic but create usize locals (e.g., new_file_offset_usize) and
use those in memmove, `@memcpy`, `@memset`, self.data.items.len assignments, and the
ensureTotalCapacity call in writeBunSection.

Comment on lines +904 to +953
.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;
},

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.

Comment thread test/bundler/bun-build-compile.test.ts Outdated
@claude

claude Bot commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

Code Review

Newest first

🟡 b9ac0 — 1 issue found

Issue File
🟡 Dynamic require("fs") should be module-scope import test/bundler/bun-build-compile.test.ts

@claude

claude Bot commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

(Superseded by updated review below)

Comment thread test/bundler/bun-build-compile.test.ts Outdated
@claude

claude Bot commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

Code Review

Newest first

dcb81 — No issues found


91993 — No issues found


🟡 b9ac0 — 1 issue found

Issue File
🟡 Dynamic require("fs") should be module-scope import test/bundler/bun-build-compile.test.ts

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { isArm64, isLinux, isMacOS, isMusl, isWindows, tempDir } from "harness";

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.

Fixed in 24d6f5e — moved to module-scope import { chmodSync } from "node:fs" at the top of the file.

@claude

claude Bot commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

Newest first

5268a — Looks good!

Reviewed 6 files across src/ and test/: replaces the trailer-based file-append approach for Linux standalone binaries with an ELF section-based approach that embeds module graph data by creating a PT_LOAD segment to memory-map the data at runtime.

Previous reviews

52c68 — Looks good!

Reviewed 6 files across src/ and test/: implements an ELF section approach for embedding module graphs in standalone Linux binaries, replacing the previous /proc/self/exe file-read approach so that standalone executables work correctly even with execute-only permissions.

Previous reviews

🟡 bf03e — 1 issue(s) found

Issue Severity File
Use bun.strings.eqlComptime instead of std.mem.eql 🟡 src/elf.zig
Previous reviews

Comment thread src/elf.zig Outdated
Comment thread src/elf.zig

fn pageSize(ehdr: Elf64_Ehdr) u64 {
return switch (ehdr.e_machine) {
.AARCH64, .PPC64 => 0x10000, // 64KB

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note that our current executable is still 4 KB pages on ARM64 iirc?

dylan-conway and others added 6 commits February 21, 2026 14:08
Standalone executables on Linux previously read their embedded module
graph from /proc/self/exe at startup, which fails when the binary has
execute-only permissions (chmod 111). This mirrors the approach already
used on macOS (__BUN,__bun section) and Windows (.bun PE section).

Build time: places a BUN_COMPILED symbol in a .bun ELF section via
__attribute__((section(".bun"))). At bun build --compile time, appends
the module graph to the end of the file, converts PT_GNU_STACK into a
PT_LOAD segment to map it, and stores the new vaddr at the original
BUN_COMPILED location.

Runtime: BUN_COMPILED.size holds either 0 (not standalone) or the vaddr
of the appended data. The kernel maps it via PT_LOAD during execve, so
the runtime just dereferences a pointer — zero file I/O, no read
permission needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
MSVC limits alignment to 8192 bytes. BlobHeader has 16KB alignment
which is only needed on macOS and Linux (for the section-based
standalone approach). Windows uses a different PE section strategy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…equire

Replace dynamic `require("fs")` inside test functions with a module-scope
`import { chmodSync } from "node:fs"` per test/CLAUDE.md guidelines.

https://claude.ai/code/session_01UujMs6n1JkfK5Sr18JM6du
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/elf-section-standalone branch from bf03ea4 to 52c68b8 Compare February 21, 2026 22:08

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/elf.zig`:
- Line 18: Replace uses of std.mem.eql for ELF magic and other short string
comparisons with the SIMD-accelerated bun.strings.eql utility: locate the
comparison using std.mem.eql(u8, ehdr.e_ident[0..4], "\x7fELF") and change it to
call bun.strings.eql for the same byte slices, and likewise update the
comparisons referenced around the section-name checks (the occurrences noted at
the block near the code handling section names around the lines mentioned).
Ensure you import or reference bun.strings if not already available and keep the
same types (u8 slices) so the equality checks remain equivalent.
- Around line 194-197: The slice indexing in readShdr uses offset (a u64) which
is invalid for slice indices; convert offset to usize before slicing. In
function readShdr, compute const offset_usize = `@intCast`(usize, offset) (or cast
when indexing) and use self.data.items[offset_usize..][0..@sizeOf(Elf64_Shdr)]
so the slice expression accepts a usize; keep the rest of the logic and types
(table_offset, index, Elf64_Shdr) unchanged.
- Around line 175-176: The slice operations use u64 values from shstrtab_shdr
(shstrtab_shdr.sh_offset and shstrtab_shdr.sh_size) as indices, which must be
cast to usize; change the variables used in the bounds check and slice
(strtab_offset and strtab_size) to usize (e.g., set strtab_offset =
`@intCast`(usize, shstrtab_shdr.sh_offset) and strtab_size = `@intCast`(usize,
shstrtab_shdr.sh_size)) before comparing to self.data.items.len and constructing
the slice so the bounds check and
self.data.items[strtab_offset..][0..strtab_size] compile correctly.

In `@src/StandaloneModuleGraph.zig`:
- Around line 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.

In `@test/bundler/bun-build-compile.test.ts`:
- Around line 192-341: The bundler tests in the describe("ELF section", ...)
block use test(...) but should use the itBundled helper for bundler/transpiler
tests; update each test invocation (the three tests named "compiled binary runs
with execute-only permissions", "compiled binary with large payload runs
correctly", "compiled binary with large payload runs with execute-only
permissions", and "compiled binary has .bun ELF section") to call itBundled(...)
with the same async function body and arguments so the tests run under the
bundler harness; keep the describe block and test bodies unchanged except for
replacing the test keyword with itBundled.

---

Duplicate comments:
In `@src/bun.zig`:
- Around line 3705-3708: The three imports macho, pe, elf and the new valkey
import are placed too early; move the new `@import`("./valkey/index.zig") into the
existing bottom import block alongside the other module imports (macho, pe, elf)
so all `@import` statements are grouped at the file bottom following the
repository Zig import convention, ensuring the formatter/auto-ordering keeps
them together.

In `@src/elf.zig`:
- Around line 87-121: Several u64 variables are being used as slice indices and
in ensureTotalCapacity, which require usize; cast each such value to usize when
used for slicing or capacity calls (e.g., call
self.data.ensureTotalCapacity(`@intCast`(usize, total_new_size)), use
`@intCast`(usize, new_file_offset) / new_shdr_offset / shdr_table_size /
padding_start / bun_section_offset where passed to bun.memmove, slice
expressions, `@memset`, `@memcpy`, and std.mem.writeInt targets). Update all uses
around self.data.ensureTotalCapacity, bun.memmove, the slice ranges for
memset/memcpy, the padding calculation, and the final std.mem.writeInt call to
perform `@intCast`(usize, ...) so indices and lengths are the correct type.
- Around line 26-29: Add an errdefer to ensure the Managed(u8) buffer is freed
if subsequent allocation fails: after creating and filling `data` (the
Managed(u8) instance used to hold `elf_data`) and before calling
`allocator.create(ElfFile)`, add `errdefer data.deinit()` so that if
`allocator.create(ElfFile)` errors the `data` buffer is deinitialized and not
leaked; keep the call placement around the `data` lifetime (created/appended to
`data` then `errdefer data.deinit()` before `const self = try
allocator.create(ElfFile)`) so normal success paths are unaffected.

Comment thread src/elf.zig Outdated
Comment thread src/elf.zig
Comment on lines +175 to +176
if (strtab_offset + strtab_size > self.data.items.len) return error.InvalidElfFile;
const strtab = self.data.items[strtab_offset..][0..strtab_size];

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

Cast strtab_offset and strtab_size to usize for slice operations.

shstrtab_shdr.sh_offset and shstrtab_shdr.sh_size are u64 (from Elf64_Shdr), but Zig requires usize for slice indices. This will fail to compile.

Proposed fix
-        if (strtab_offset + strtab_size > self.data.items.len) return error.InvalidElfFile;
-        const strtab = self.data.items[strtab_offset..][0..strtab_size];
+        const strtab_offset_usize: usize = `@intCast`(strtab_offset);
+        const strtab_size_usize: usize = `@intCast`(strtab_size);
+        if (strtab_offset + strtab_size > self.data.items.len) return error.InvalidElfFile;
+        const strtab = self.data.items[strtab_offset_usize..][0..strtab_size_usize];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (strtab_offset + strtab_size > self.data.items.len) return error.InvalidElfFile;
const strtab = self.data.items[strtab_offset..][0..strtab_size];
const strtab_offset_usize: usize = `@intCast`(strtab_offset);
const strtab_size_usize: usize = `@intCast`(strtab_size);
if (strtab_offset + strtab_size > self.data.items.len) return error.InvalidElfFile;
const strtab = self.data.items[strtab_offset_usize..][0..strtab_size_usize];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/elf.zig` around lines 175 - 176, The slice operations use u64 values from
shstrtab_shdr (shstrtab_shdr.sh_offset and shstrtab_shdr.sh_size) as indices,
which must be cast to usize; change the variables used in the bounds check and
slice (strtab_offset and strtab_size) to usize (e.g., set strtab_offset =
`@intCast`(usize, shstrtab_shdr.sh_offset) and strtab_size = `@intCast`(usize,
shstrtab_shdr.sh_size)) before comparing to self.data.items.len and constructing
the slice so the bounds check and
self.data.items[strtab_offset..][0..strtab_size] compile correctly.

Comment thread src/elf.zig
Comment on lines +194 to +197
fn readShdr(self: *const ElfFile, table_offset: u64, index: u16) Elf64_Shdr {
const offset = table_offset + @as(u64, index) * @sizeOf(Elf64_Shdr);
return std.mem.bytesAsValue(Elf64_Shdr, self.data.items[offset..][0..@sizeOf(Elf64_Shdr)]).*;
}

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

Cast offset to usize for slice indexing.

offset is computed as u64 but used as a slice index, which requires usize.

Proposed fix
     fn readShdr(self: *const ElfFile, table_offset: u64, index: u16) Elf64_Shdr {
         const offset = table_offset + `@as`(u64, index) * `@sizeOf`(Elf64_Shdr);
-        return std.mem.bytesAsValue(Elf64_Shdr, self.data.items[offset..][0..@sizeOf(Elf64_Shdr)]).*;
+        const offset_usize: usize = `@intCast`(offset);
+        return std.mem.bytesAsValue(Elf64_Shdr, self.data.items[offset_usize..][0..@sizeOf(Elf64_Shdr)]).*;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn readShdr(self: *const ElfFile, table_offset: u64, index: u16) Elf64_Shdr {
const offset = table_offset + @as(u64, index) * @sizeOf(Elf64_Shdr);
return std.mem.bytesAsValue(Elf64_Shdr, self.data.items[offset..][0..@sizeOf(Elf64_Shdr)]).*;
}
fn readShdr(self: *const ElfFile, table_offset: u64, index: u16) Elf64_Shdr {
const offset = table_offset + `@as`(u64, index) * `@sizeOf`(Elf64_Shdr);
const offset_usize: usize = `@intCast`(offset);
return std.mem.bytesAsValue(Elf64_Shdr, self.data.items[offset_usize..][0..@sizeOf(Elf64_Shdr)]).*;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/elf.zig` around lines 194 - 197, The slice indexing in readShdr uses
offset (a u64) which is invalid for slice indices; convert offset to usize
before slicing. In function readShdr, compute const offset_usize =
`@intCast`(usize, offset) (or cast when indexing) and use
self.data.items[offset_usize..][0..@sizeOf(Elf64_Shdr)] so the slice expression
accepts a usize; keep the rest of the logic and types (table_offset, index,
Elf64_Shdr) unchanged.

Comment on lines +904 to +952
.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;

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 +192 to +341
if (isLinux) {
describe("ELF section", () => {
test("compiled binary runs with execute-only permissions", async () => {
using dir = tempDir("build-compile-exec-only", {
"app.js": `console.log("exec-only-output");`,
});

const outfile = join(dir + "", "app-exec-only");
const result = await Bun.build({
entrypoints: [join(dir + "", "app.js")],
compile: {
outfile,
},
});

expect(result.success).toBe(true);

chmodSync(result.outputs[0].path, 0o111);

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("exec-only-output");
expect(exitCode).toBe(0);
});

test("compiled binary with large payload runs correctly", async () => {
// Generate a string payload >16KB to exceed the initial .bun section allocation
// (BUN_COMPILED is aligned to 16KB). This forces the expansion path in elf.zig
// which appends data to the end of the file and converts PT_GNU_STACK to PT_LOAD.
const largeString = Buffer.alloc(20000, "x").toString();
using dir = tempDir("build-compile-large-payload", {
"app.js": `const data = "${largeString}"; console.log("large-payload-" + data.length);`,
});

const outfile = join(dir + "", "app-large");
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).toContain("large-payload-20000");
expect(exitCode).toBe(0);
});

test("compiled binary with large payload runs with execute-only permissions", async () => {
// Same as above but also verifies execute-only works with the expansion path
const largeString = Buffer.alloc(20000, "y").toString();
using dir = tempDir("build-compile-large-exec-only", {
"app.js": `const data = "${largeString}"; console.log("large-exec-only-" + data.length);`,
});

const outfile = join(dir + "", "app-large-exec-only");
const result = await Bun.build({
entrypoints: [join(dir + "", "app.js")],
compile: {
outfile,
},
});

expect(result.success).toBe(true);

chmodSync(result.outputs[0].path, 0o111);

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).toContain("large-exec-only-20000");
expect(exitCode).toBe(0);
});

test("compiled binary has .bun ELF section", async () => {
using dir = tempDir("build-compile-elf-section", {
"app.js": `console.log("elf-section-test");`,
});

const outfile = join(dir + "", "app-elf-section");
const result = await Bun.build({
entrypoints: [join(dir + "", "app.js")],
compile: {
outfile,
},
});

expect(result.success).toBe(true);

// Verify .bun ELF section exists by reading section headers
const file = Bun.file(result.outputs[0].path);
const bytes = new Uint8Array(await file.arrayBuffer());

// Parse ELF header to find section headers
const view = new DataView(bytes.buffer);
// e_shoff at offset 40 (little-endian u64)
const shoff = Number(view.getBigUint64(40, true));
// e_shentsize at offset 58
const shentsize = view.getUint16(58, true);
// e_shnum at offset 60
const shnum = view.getUint16(60, true);
// e_shstrndx at offset 62
const shstrndx = view.getUint16(62, true);

// Read .shstrtab section header to get string table
const strtabOff = shoff + shstrndx * shentsize;
const strtabFileOffset = Number(view.getBigUint64(strtabOff + 24, true));
const strtabSize = Number(view.getBigUint64(strtabOff + 32, true));

const decoder = new TextDecoder();
let foundBunSection = false;
for (let i = 0; i < shnum; i++) {
const hdrOff = shoff + i * shentsize;
const nameIdx = view.getUint32(hdrOff, true);
if (nameIdx < strtabSize) {
// Read null-terminated string from strtab
let end = strtabFileOffset + nameIdx;
while (end < bytes.length && bytes[end] !== 0) end++;
const name = decoder.decode(bytes.slice(strtabFileOffset + nameIdx, end));
if (name === ".bun") {
foundBunSection = true;
// Verify the section has non-zero size
const shSize = Number(view.getBigUint64(hdrOff + 32, true));
expect(shSize).toBeGreaterThan(0);
break;
}
}
}
expect(foundBunSection).toBe(true);
});
});

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

Use itBundled for the new bundler tests.

These are bundler tests under test/bundler/, so they should be expressed with the itBundled helper rather than test(...).

As per coding guidelines: For bundler and transpiler tests, place tests in test/bundler/ and use itBundled helper.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/bundler/bun-build-compile.test.ts` around lines 192 - 341, The bundler
tests in the describe("ELF section", ...) block use test(...) but should use the
itBundled helper for bundler/transpiler tests; update each test invocation (the
three tests named "compiled binary runs with execute-only permissions",
"compiled binary with large payload runs correctly", "compiled binary with large
payload runs with execute-only permissions", and "compiled binary has .bun ELF
section") to call itBundled(...) with the same async function body and arguments
so the tests run under the bundler harness; keep the describe block and test
bodies unchanged except for replacing the test keyword with itBundled.

@coderabbitai coderabbitai Bot left a comment

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.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/elf.zig`:
- Around line 60-135: In writeBunSection, several offsets/sizes are u64 but are
later used as slice indices and passed to self.data.ensureTotalCapacity which
require usize; change by creating usize versions of the computed u64 values
(e.g., new_file_offset_usize = `@as`(usize, new_file_offset),
new_shdr_offset_usize = `@as`(usize, new_shdr_offset), total_new_size_usize =
`@as`(usize, total_new_size), shdr_table_size_usize = `@as`(usize, shdr_table_size),
old_shdr_offset_usize = `@as`(usize, old_shdr_offset), bun_section_offset_usize =
`@as`(usize, bun_section_offset), shdr_offset_usize = `@as`(usize, shdr_offset),
padding_start_usize = `@as`(usize, padding_start)) and use those usize variables
for ensureTotalCapacity, setting self.data.items.len, all slice indexing,
memmove/memcpy/memset, and std.mem.writeInt calls so the code compiles and
avoids truncation on 32-bit hosts.
- Around line 182-221: In findBunSection and readShdr, cast all ELF
offsets/counts to usize before using them for bounds checks or slicing: create
local usize variables for ehdr.e_shoff (shdr_table_offset), `@sizeOf`(Elf64_Shdr)
(shdr_size if needed), shdr.sh_offset, shdr.sh_size, shdr.sh_name and the
computed offset in readShdr; use `@intCast/`@as(usize, ...) to convert ei
e_shoff/u64 and sh_* u32/u64 values and then use those usize locals for
comparisons, slicing (strtab = self.data.items[strtab_offset..][0..strtab_size])
and for computing the table entry address in readShdr so the code compiles and
avoids mixing integer widths.

Comment on lines 903 to +925
},
.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();

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.

@Jarred-Sumner
Jarred-Sumner merged commit 66f7c41 into main Mar 20, 2026
21 of 30 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/elf-section-standalone branch March 20, 2026 04:51
sosukesuzuki added a commit that referenced this pull request Mar 21, 2026
The Linux bundler_compile.test.ts hang is a pre-existing issue from
PR #26923 (ELF section for standalone binaries), not from the WebKit
upgrade. That PR added describe.concurrent to bundler_compile.test.ts
and changed the Linux inject path from a simple append to reading and
rewriting the entire executable. With 20 concurrent compiles of a
~500MB profile binary, this causes resource exhaustion. The ELF PR's
own CI (build #40193) shows the same failures.
sosukesuzuki added a commit that referenced this pull request Mar 21, 2026
PR #26923 changed this to describe.concurrent and also changed the
Linux ELF inject path from a simple append to reading + rewriting the
entire executable. With profile builds at ~500MB, running 20 concurrent
compiles exhausts CI resources. Sequential execution avoids this
resource contention. The ELF PR's own CI (build #40193) shows identical
timeouts on all Linux x64 targets.
structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
…sh#26923)

## Summary

- Standalone executables on Linux previously read their embedded module
graph from `/proc/self/exe` at startup, which fails when the binary has
execute-only permissions (`chmod 111`)
- Now uses an ELF section approach (`.bun` section with `BUN_COMPILED`
linker symbol), matching the existing macOS (`__BUN,__bun`) and Windows
(`.bun` PE section) implementations
- At runtime, the kernel maps the data via `PT_LOAD` during `execve` —
zero file I/O, no read permission needed

### How it works

**Build time** (`src/elf.zig`): Appends the module graph to the end of
the ELF file, converts `PT_GNU_STACK` into a `PT_LOAD` segment to map
it, and stores the new virtual address at the original `BUN_COMPILED`
location.

**Runtime** (`StandaloneModuleGraph.zig`): `BUN_COMPILED.size` holds
either `0` (not standalone) or the vaddr of the appended data. Just
dereferences a pointer to get the module graph.

### Files changed

| File | Change |
|---|---|
| `src/elf.zig` | New — ELF manipulation module (229 lines) |
| `src/bun.zig` | Added `elf` import |
| `src/bun.js/bindings/c-bindings.cpp` | Added Linux `BUN_COMPILED` in
`.bun` section |
| `src/StandaloneModuleGraph.zig` | Added `ELF` struct, Linux
inject/read paths, removed `/proc/self/exe` reading |
| `test/bundler/bun-build-compile.test.ts` | 4 new Linux-only tests |

## Test plan

- [x] `bun bd test test/bundler/bun-build-compile.test.ts` — 10/10 pass
- [x] `bun bd test test/bundler/bun-build-compile-sourcemap.test.ts` —
5/5 pass
- [x] `bun bd test test/bundler/compile-argv.test.ts` — 10/10 pass
- [x] Manual: small payload + `chmod 111` — works
- [x] Manual: large payload (>16KB, forces append path) + `chmod 111` —
works

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Jarred-Sumner pushed a commit that referenced this pull request Apr 30, 2026
…NU_STACK (#29967)

Fixes #29963.

## Reproduction

On WSL1, Linux executables produced by `bun build --compile` since
1.3.13 fail at kernel `execve` with `ENOEXEC (Exec format error)` before
any userland code runs:

```
$ ./hello-1.3.13
bash: ./hello-1.3.13: cannot execute binary file: Exec format error
$ strace -f ./hello-1.3.13 2>&1 | head -1
execve("./hello-1.3.13", …) = -1 ENOEXEC (Exec format error)
```

Invoking the binary through the loader works
(`/lib64/ld-linux-x86-64.so.2 ./hello-1.3.13`), which isolates the
failure to WSL1's kernel-side ELF header parsing. The issue author
binary-patched the appended phdr's `p_type` field from `PT_LOAD` back to
`PT_GNU_STACK` and confirmed WSL1 then accepts the binary — proving the
trigger is specifically the late `PT_LOAD`.

## Cause

#26923 introduced the "append .bun payload, convert PT_GNU_STACK into
PT_LOAD" approach in `src/elf.zig`. That produces an ELF with 4 PT_LOAD
segments, the last one mapped only by the ex-PT_GNU_STACK slot:

```
LOAD … 0x4000
LOAD … 0x4000
LOAD … 0x4000
LOAD … .bun … 0x1000   ← WSL1 execve rejects this layout
```

Opencode users tracked this regression (opencode 1.14.20 → 1.14.21 = Bun
1.3.11 → 1.3.13), and WSL1 has been unable to run any recent opencode /
compiled-bun binary.

## Fix

Grow the existing writable PT_LOAD to cover the appended payload — the
layout a linker would produce natively. Leave PT_GNU_STACK alone.

`src/elf.zig` `writeBunSection`:

- Find the `PF_W` PT_LOAD (where `.bun` already lives as a
BlobHeader-aligned PROGBITS section).
- Place the payload at `rw.p_offset + alignUp(rw.p_memsz, rw.p_align)`
in file and the matching vaddr — the equal offsets automatically satisfy
`p_vaddr mod p_align == p_offset mod p_align`, the ELF constraint for
extending a PT_LOAD.
- Grow `p_filesz` and `p_memsz` to cover the new tail. Equal values are
fine — the extension is entirely file-backed, no new BSS gap.
- Relocate the non-ALLOC sections (`.comment`, `.symtab`, `.strtab`,
`.shstrtab`, debug info) and the section header table past the new
payload, since their old file range is now inside the extended PT_LOAD
and would otherwise get mapped into what was previously BSS. Zero-fill
that range so zero-initialized statics stay zeroed.
- Update every moved section header's `sh_offset`.

## Verification

**On this (non-WSL1) Linux host:**

```
$ readelf -l hello-new | grep -E 'LOAD|GNU_STACK'
LOAD ... 0x0000000019f1b1a0 ... RW 0x4000    ← grown writable PT_LOAD, now covers .bun
LOAD ... 0x0000000000000000 ... R
LOAD ... 0x0000000007fd3200 ... R E
GNU_STACK ...                                 ← preserved

$ readelf -S hello-new | grep '\.bun'
[31] .bun  PROGBITS 000000001e6df1a0  ...
```

The `.bun` vaddr (`0x1e6df1a0`) falls inside the RW segment's `[p_vaddr,
p_vaddr + p_memsz)` range. Only 3 PT_LOADs. PT_GNU_STACK intact.

Execute-only permissions (`chmod 111`) still work — the kernel maps
`.bun` at `execve`, no `/proc/self/exe` read needed.

**Test** (`test/bundler/bun-build-compile.test.ts`): new test walks the
compiled ELF's program headers and asserts `PT_GNU_STACK` is present,
the writable PT_LOAD's vaddr range covers `.bun`, and the LOAD count
stays at 3. Gates the shape directly so we don't need a WSL1 host to
validate the fix. Fails on main (no PT_GNU_STACK), passes with this
change.

## Related

- #26923 — introduced the `.bun` ELF section approach (replaces
`/proc/self/exe` trailer read; still preserved here).
- #24742, #29290 — PT_INTERP normalization for Nix/Guix hosts.
Untouched.
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…sh#26923)

## Summary

- Standalone executables on Linux previously read their embedded module
graph from `/proc/self/exe` at startup, which fails when the binary has
execute-only permissions (`chmod 111`)
- Now uses an ELF section approach (`.bun` section with `BUN_COMPILED`
linker symbol), matching the existing macOS (`__BUN,__bun`) and Windows
(`.bun` PE section) implementations
- At runtime, the kernel maps the data via `PT_LOAD` during `execve` —
zero file I/O, no read permission needed

### How it works

**Build time** (`src/elf.zig`): Appends the module graph to the end of
the ELF file, converts `PT_GNU_STACK` into a `PT_LOAD` segment to map
it, and stores the new virtual address at the original `BUN_COMPILED`
location.

**Runtime** (`StandaloneModuleGraph.zig`): `BUN_COMPILED.size` holds
either `0` (not standalone) or the vaddr of the appended data. Just
dereferences a pointer to get the module graph.

### Files changed

| File | Change |
|---|---|
| `src/elf.zig` | New — ELF manipulation module (229 lines) |
| `src/bun.zig` | Added `elf` import |
| `src/bun.js/bindings/c-bindings.cpp` | Added Linux `BUN_COMPILED` in
`.bun` section |
| `src/StandaloneModuleGraph.zig` | Added `ELF` struct, Linux
inject/read paths, removed `/proc/self/exe` reading |
| `test/bundler/bun-build-compile.test.ts` | 4 new Linux-only tests |

## Test plan

- [x] `bun bd test test/bundler/bun-build-compile.test.ts` — 10/10 pass
- [x] `bun bd test test/bundler/bun-build-compile-sourcemap.test.ts` —
5/5 pass
- [x] `bun bd test test/bundler/compile-argv.test.ts` — 10/10 pass
- [x] Manual: small payload + `chmod 111` — works
- [x] Manual: large payload (>16KB, forces append path) + `chmod 111` —
works

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…NU_STACK (oven-sh#29967)

Fixes oven-sh#29963.

## Reproduction

On WSL1, Linux executables produced by `bun build --compile` since
1.3.13 fail at kernel `execve` with `ENOEXEC (Exec format error)` before
any userland code runs:

```
$ ./hello-1.3.13
bash: ./hello-1.3.13: cannot execute binary file: Exec format error
$ strace -f ./hello-1.3.13 2>&1 | head -1
execve("./hello-1.3.13", …) = -1 ENOEXEC (Exec format error)
```

Invoking the binary through the loader works
(`/lib64/ld-linux-x86-64.so.2 ./hello-1.3.13`), which isolates the
failure to WSL1's kernel-side ELF header parsing. The issue author
binary-patched the appended phdr's `p_type` field from `PT_LOAD` back to
`PT_GNU_STACK` and confirmed WSL1 then accepts the binary — proving the
trigger is specifically the late `PT_LOAD`.

## Cause

oven-sh#26923 introduced the "append .bun payload, convert PT_GNU_STACK into
PT_LOAD" approach in `src/elf.zig`. That produces an ELF with 4 PT_LOAD
segments, the last one mapped only by the ex-PT_GNU_STACK slot:

```
LOAD … 0x4000
LOAD … 0x4000
LOAD … 0x4000
LOAD … .bun … 0x1000   ← WSL1 execve rejects this layout
```

Opencode users tracked this regression (opencode 1.14.20 → 1.14.21 = Bun
1.3.11 → 1.3.13), and WSL1 has been unable to run any recent opencode /
compiled-bun binary.

## Fix

Grow the existing writable PT_LOAD to cover the appended payload — the
layout a linker would produce natively. Leave PT_GNU_STACK alone.

`src/elf.zig` `writeBunSection`:

- Find the `PF_W` PT_LOAD (where `.bun` already lives as a
BlobHeader-aligned PROGBITS section).
- Place the payload at `rw.p_offset + alignUp(rw.p_memsz, rw.p_align)`
in file and the matching vaddr — the equal offsets automatically satisfy
`p_vaddr mod p_align == p_offset mod p_align`, the ELF constraint for
extending a PT_LOAD.
- Grow `p_filesz` and `p_memsz` to cover the new tail. Equal values are
fine — the extension is entirely file-backed, no new BSS gap.
- Relocate the non-ALLOC sections (`.comment`, `.symtab`, `.strtab`,
`.shstrtab`, debug info) and the section header table past the new
payload, since their old file range is now inside the extended PT_LOAD
and would otherwise get mapped into what was previously BSS. Zero-fill
that range so zero-initialized statics stay zeroed.
- Update every moved section header's `sh_offset`.

## Verification

**On this (non-WSL1) Linux host:**

```
$ readelf -l hello-new | grep -E 'LOAD|GNU_STACK'
LOAD ... 0x0000000019f1b1a0 ... RW 0x4000    ← grown writable PT_LOAD, now covers .bun
LOAD ... 0x0000000000000000 ... R
LOAD ... 0x0000000007fd3200 ... R E
GNU_STACK ...                                 ← preserved

$ readelf -S hello-new | grep '\.bun'
[31] .bun  PROGBITS 000000001e6df1a0  ...
```

The `.bun` vaddr (`0x1e6df1a0`) falls inside the RW segment's `[p_vaddr,
p_vaddr + p_memsz)` range. Only 3 PT_LOADs. PT_GNU_STACK intact.

Execute-only permissions (`chmod 111`) still work — the kernel maps
`.bun` at `execve`, no `/proc/self/exe` read needed.

**Test** (`test/bundler/bun-build-compile.test.ts`): new test walks the
compiled ELF's program headers and asserts `PT_GNU_STACK` is present,
the writable PT_LOAD's vaddr range covers `.bun`, and the LOAD count
stays at 3. Gates the shape directly so we don't need a WSL1 host to
validate the fix. Fails on main (no PT_GNU_STACK), passes with this
change.

## Related

- oven-sh#26923 — introduced the `.bun` ELF section approach (replaces
`/proc/self/exe` trailer read; still preserved here).
- oven-sh#24742, oven-sh#29290 — PT_INTERP normalization for Nix/Guix hosts.
Untouched.
XuHaoJun added a commit to XuHaoJun/githunk that referenced this pull request Sep 12, 2026
githunk idles at ~91 MB RSS against lazygit's ~20 MB. The Bun runtime is not
the cause: a compiled `console.log` idles at 14 MB, below lazygit's Go runtime.

Records the measured ladder, the mechanism (`--compile` maps the embedded
module graph as a PT_LOAD segment, so bundled-but-unimported code is charged to
RSS unconditionally — oven-sh/bun#26923), and the approaches tried and
rejected, so the negative results are not re-derived later. Notably: a dynamic
import alone changes nothing without `--splitting`, `bundledLanguages` cannot
be tree-shaken because it is a map of stored import thunks, and a `tsconfig`
`paths` alias does not apply to a bare specifier imported from inside
node_modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
XuHaoJun added a commit to XuHaoJun/githunk that referenced this pull request Sep 12, 2026
`--compile` maps the embedded module graph as a PT_LOAD segment during execve
(oven-sh/bun#26923), so bundled-but-unimported code is charged to RSS whether
or not it runs. Without `--splitting`, converting an import to `await import()`
produced a byte-identical binary and identical RSS; with it, deferred chunks
stay uninstantiated at startup. `--splitting` is supported alongside `--compile`
(https://bun.com/docs/bundler/executables).

This lands the flags first so the lazy Branch Review chunk that follows has
something to defer into; on its own the flag pair is already a small win.

Measured on the compiled binary, idle in this repository under a pty:
binary 107.5 MB -> 105.3 MB; idle RSS 91 MB -> 88/91/86 MB over three runs
against a 91 MB baseline reference. Cross-run variance is roughly +/-5 MB, so
the flags alone are worth a few MB; the larger win is the deferred chunk.

`--bytecode` is deliberately not set yet: it needs `--format=esm` under
`--compile` because @opentui/core uses top-level await, and it grows the binary
to ~127 MB for startup time rather than memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
XuHaoJun added a commit to XuHaoJun/githunk that referenced this pull request Sep 12, 2026
React, @opentui/react, the workspace components and the syntax highlighter
behind them are reachable only from Branch Review, but a static import puts
them in the repository screen's startup image: `--compile` maps the embedded
module graph as a PT_LOAD segment during execve (oven-sh/bun#26923). Routing
the host through a dynamic import lets `--splitting` keep that chunk
uninstantiated until the screen is actually opened.

`createReviewView` now returns a promise, which `AppScreenController` awaits.
Shutdown gets a per-instance `reviewViewCreated` guard: the module cache in
`react-review-host-lazy` is process-wide, so it cannot decide whether *this*
controller ever built a view, and disposing unconditionally would pull the
chunk back in at teardown.

Measured on the compiled binary, idle in this repository under a pty, medians
of three runs in one batch: 92 MB -> 87 MB. Binary 107.5 MB -> 105.3 MB.
Full suite green across eight consecutive runs (1794 pass, 0 fail).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
XuHaoJun added a commit to XuHaoJun/githunk that referenced this pull request Sep 12, 2026
* docs: spec the memory footprint reduction work

githunk idles at ~91 MB RSS against lazygit's ~20 MB. The Bun runtime is not
the cause: a compiled `console.log` idles at 14 MB, below lazygit's Go runtime.

Records the measured ladder, the mechanism (`--compile` maps the embedded
module graph as a PT_LOAD segment, so bundled-but-unimported code is charged to
RSS unconditionally — oven-sh/bun#26923), and the approaches tried and
rejected, so the negative results are not re-derived later. Notably: a dynamic
import alone changes nothing without `--splitting`, `bundledLanguages` cannot
be tree-shaken because it is a map of stored import thunks, and a `tsconfig`
`paths` alias does not apply to a bare specifier imported from inside
node_modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: compile the binary with --splitting and --minify

`--compile` maps the embedded module graph as a PT_LOAD segment during execve
(oven-sh/bun#26923), so bundled-but-unimported code is charged to RSS whether
or not it runs. Without `--splitting`, converting an import to `await import()`
produced a byte-identical binary and identical RSS; with it, deferred chunks
stay uninstantiated at startup. `--splitting` is supported alongside `--compile`
(https://bun.com/docs/bundler/executables).

This lands the flags first so the lazy Branch Review chunk that follows has
something to defer into; on its own the flag pair is already a small win.

Measured on the compiled binary, idle in this repository under a pty:
binary 107.5 MB -> 105.3 MB; idle RSS 91 MB -> 88/91/86 MB over three runs
against a 91 MB baseline reference. Cross-run variance is roughly +/-5 MB, so
the flags alone are worth a few MB; the larger win is the deferred chunk.

`--bytecode` is deliberately not set yet: it needs `--format=esm` under
`--compile` because @opentui/core uses top-level await, and it grows the binary
to ~127 MB for startup time rather than memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: load the Branch Review React tree only when the screen opens

React, @opentui/react, the workspace components and the syntax highlighter
behind them are reachable only from Branch Review, but a static import puts
them in the repository screen's startup image: `--compile` maps the embedded
module graph as a PT_LOAD segment during execve (oven-sh/bun#26923). Routing
the host through a dynamic import lets `--splitting` keep that chunk
uninstantiated until the screen is actually opened.

`createReviewView` now returns a promise, which `AppScreenController` awaits.
Shutdown gets a per-instance `reviewViewCreated` guard: the module cache in
`react-review-host-lazy` is process-wide, so it cannot decide whether *this*
controller ever built a view, and disposing unconditionally would pull the
chunk back in at teardown.

Measured on the compiled binary, idle in this repository under a pty, medians
of three runs in one batch: 92 MB -> 87 MB. Binary 107.5 MB -> 105.3 MB.
Full suite green across eight consecutive runs (1794 pass, 0 fail).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the Shiki stub as a measured negative result

Phase 2 aliased the `shiki` barrel to a stub whose `bundledLanguages` held 60
curated grammars instead of 346, so the binary would stop carrying the rest.
It was built, and highlighting was verified in a compiled binary against a
real-Shiki build of the same probe — identical tokens for typescript and ruby.

It bought 5 MB of binary and no memory: 88 MB against phase 1's 87 MB, medians
of three runs in one batch. Phase 1 had already solved it. Shiki's grammar
entries are dynamic imports to begin with; what made them expensive was
`--compile` without `--splitting` mapping the entire graph into the process
image. Narrowing 346 deferred chunks to 60 deferred chunks changes nothing that
is resident.

Reverted, because the stub's price is a language allowlist: any grammar outside
the curated set silently loses highlighting. That is a real regression for zero
progress on the goal.

Also records where the remaining 87 MB sits. `import @opentui/core` alone is
+39 MB, and in its own binary that import takes 16 MB -> 50 MB with 36.3 MB of
it binary file pages. OpenTUI already embeds its tree-sitter grammars with
`with { type: "file" }`, so there is no payload to strip; the cost is its own
JavaScript plus extracting the 6.1 MB libopentui.so at import. The ~70 MB target
is not reachable from githunk's own code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: format rebased additions

* fix: release closed branch review session

* test: stabilize review session gc regression

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants