Skip to content

compile: extend existing writable PT_LOAD instead of repurposing PT_GNU_STACK#29967

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/7b28e65d/fix-compile-wsl1-enoexec
Apr 30, 2026
Merged

compile: extend existing writable PT_LOAD instead of repurposing PT_GNU_STACK#29967
Jarred-Sumner merged 2 commits into
mainfrom
farm/7b28e65d/fix-compile-wsl1-enoexec

Conversation

@robobun

@robobun robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

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

…sing PT_GNU_STACK (#29963)

WSL1's kernel ELF loader rejects `execve` with ENOEXEC when it sees a
late PT_LOAD created by rewriting the PT_GNU_STACK slot. Compiled
outputs from `bun build --compile` have been unable to launch on WSL1
since this approach was introduced in #26923.

Instead, grow the existing writable PT_LOAD — the shape a linker would
produce natively — and leave PT_GNU_STACK alone. Place the appended
payload at the segment's aligned tail so the mandatory
`p_vaddr mod p_align == p_offset mod p_align` congruence holds when
we extend p_filesz/p_memsz, and relocate the non-ALLOC sections
(\.comment, \.symtab, \.strtab, \.shstrtab, debug info) + the old
section header table past the new payload. Their old file range is now
inside the extended PT_LOAD's file-backed region, so it must read as
zero to preserve BSS semantics for zero-initialized statics.

The runtime contract is unchanged: `BUN_COMPILED.size` holds the
payload vaddr and the kernel mmaps it at `execve`. Execute-only
permissions continue to work (no /proc/self/exe read needed).

Regression guard walks the compiled ELF's program headers and asserts
PT_GNU_STACK survives, the writable PT_LOAD covers \.bun's vaddr, and
the LOAD count stays at 3.
@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:54 PM PT - Apr 29th, 2026

@robobun, your commit e127b7c has 1 failures in Build #49332 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29967

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

bun-29967 --bun

@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Reworks ELF .bun handling to place the .bun payload inside and extend an existing writable PT_LOAD instead of creating a new PT_LOAD from PT_GNU_STACK; updates section- and program-header offsets accordingly and adds a Linux ELF regression test that verifies the new layout and runtime behavior.

Changes

Cohort / File(s) Summary
ELF .bun Payload Mapping
src/elf.zig
Rewrote writeBunSection to locate the first writable PT_LOAD, compute aligned in-segment file/virtual offsets, validate no overlap with existing memsz, grow the file, relocate the tail (non-NOBITS sections and the section-header table) after the new payload, zero the newly file-backed BSS gap, update e_shoff and all sh_offset values (special-casing .bun), and increase the target PT_LOAD's p_filesz/p_memsz. No public signatures changed.
ELF Structure Validation Test
test/bundler/bun-build-compile.test.ts
Added a Linux ELF-layout regression test that: locates .bun via section-header string table, asserts PT_GNU_STACK remains present, verifies .bun resides within and is covered by an existing writable PT_LOAD (not a new late PT_LOAD), checks PT_LOAD count remains 3, enforces 128‑byte alignment of .bun, and runs the produced binary to assert expected stdout and exit. Also updated a comment in the large-payload test to reflect the new behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: extending an existing writable PT_LOAD instead of repurposing PT_GNU_STACK for the .bun payload.
Description check ✅ Passed The description comprehensively covers both template sections: what the PR does (detailed explanation of the fix with code changes) and how it was verified (readelf output, execute-only permissions test, new test assertions).
Linked Issues check ✅ Passed The PR directly addresses all coding requirements from #29963: extends existing RW PT_LOAD to cover .bun, preserves PT_GNU_STACK, relocates non-ALLOC sections, updates section headers, maintains page alignment, and includes comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes (src/elf.zig writeBunSection logic and test/bundler/bun-build-compile.test.ts regression test) are directly scoped to fixing the WSL1 ENOEXEC issue by implementing the recommended fix shape from #29963.

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


Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

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

@claude claude 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.

This is a careful, well-reasoned rewrite of the ELF .bun embedding strategy, but it touches low-level binary-layout code that affects every bun build --compile output on Linux — worth a human pass to validate the segment-layout assumptions (RW PT_LOAD is always last in vaddr space, BSS zero-fill range is correct, non-ALLOC tail relocation covers all cases).

Extended reasoning...

Overview

This PR rewrites writeBunSection in src/elf.zig to fix #29963 (WSL1 rejects compiled binaries with ENOEXEC). Instead of repurposing the PT_GNU_STACK program header as a 4th PT_LOAD for the appended .bun payload, it now grows the existing writable PT_LOAD segment to cover the payload, relocates the non-ALLOC tail (.comment/.symtab/.strtab/shdr table/debug info) past it, zero-fills the file range that becomes the formerly-BSS region, and rewrites every moved section header's sh_offset. A new structural test in test/bundler/bun-build-compile.test.ts walks the output ELF's program headers and asserts PT_GNU_STACK is preserved, the writable PT_LOAD covers .bun, and the LOAD count stays at 3.

Security risks

No direct security surface — this operates on Bun's own binary as input (trusted), not on user-supplied ELF files, and the output is a compiled executable the user runs themselves. The added bounds checks (old_rw_file_end > old_file_size, shdr-table-within-moved-tail) and the NewVaddrCollides guard are defensive improvements over the previous code. No injection, auth, or data-exposure concerns.

Level of scrutiny

High. This is not a mechanical change — it's a near-total rewrite of the ELF segment-layout logic that runs for every Linux --compile output. Correctness depends on several layout assumptions about Bun's own binary (single writable PT_LOAD, RW segment is highest in vaddr space, everything past rw.p_offset + rw.p_filesz in the file is non-ALLOC and safe to relocate). The code documents and guards most of these, but a subtle mistake here could corrupt BSS (zero-initialized statics) or break debuggability in ways that only show up on specific builds (debug+ASAN, stripped vs. unstripped, musl vs. glibc, aarch64 64K pages). The test validates structural shape and runs the binary, which is good, but doesn't exercise every layout permutation.

Other factors

  • The implementation is thoughtfully written with extensive inline rationale, correct use of memmove for overlapping copies, and explicit alignment math that preserves the ELF p_vaddr ≡ p_offset (mod p_align) invariant.
  • Only one nit was found (stale comment in a sibling test that still references the old PT_GNU_STACK→PT_LOAD behavior).
  • The PR appears to already be merged on main (commit c2c4689 in recent history), but per the review brief I'm assessing independently.
  • Given the blast radius (all Linux compiled binaries) and the subtlety of ELF segment manipulation, I'd want a human who knows this code path to confirm the layout assumptions hold across all build configurations before signing off.

Comment thread test/bundler/bun-build-compile.test.ts
…t SIGSEGV on aarch64

The extended PT_LOAD placed .bun at `rw_phdr.p_vaddr + alignUp(p_memsz,
p_align)`, which inherits the writable segment's residue mod 128 and
breaks JSC's bytecode cache alignment: StandaloneModuleGraph writes
bytecode at payload offset 120 so that bytecode lands at
`vaddr + 8 + 120` — only 128-aligned if `vaddr % 128 == 0`. On
aarch64 that mis-alignment SIGSEGVs during bytecode deserialization
(compile/WorkerBytecodeESM, compile/ReactSSR+bytecode+*, etc.).

Pre-#29967 the code used `alignUp(max_vaddr_end, page_size)` directly
and page_size ≥ 128 kept the invariant. Restore that: anchor new_vaddr
at page_size, then derive the in-segment offset (and thus
new_file_offset) from it. ELF's `p_vaddr mod p_align == p_offset mod
p_align` is still preserved because we grow the existing segment's
filesz/memsz without touching p_offset or p_vaddr.

The regression test now also asserts `.bun`'s sh_addr is a multiple
of 128 so a future change that re-inherits the segment residue gets
caught immediately.

@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: 2

🤖 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`:
- Around line 196-219: The code currently picks the first writable PT_LOAD via
rw_phdr_index/rw_phdr but must pick the writable PT_LOAD that actually contains
the .bun payload; change the selection to scan program headers for a PT_LOAD
with PF_W whose file-range (p_offset .. p_offset + p_filesz) covers the .bun
file offset (or start of the payload) and use that header for computations of
new_vaddr, offset_in_segment, new_file_offset and for updating p_filesz/p_memsz;
apply the same fix where rw_phdr is chosen again (the analogous block around the
337-349 region) so all relocation math uses the segment that actually owns .bun
rather than the first writable segment.
- Around line 184-200: writeBunSection() currently reads Elf64_Phdr entries
using e_phoff/e_phnum without validating the program-header table bounds, which
can panic on truncated data; before the loop that computes phdr_offset and
bytesAsValue, validate that e_phoff and e_phnum define a contiguous region
inside self.data.items (e.g., check `@intCast`(usize, ehdr.e_phoff) + ehdr.e_phnum
* phdr_size <= self.data.items.len) and return error.InvalidElfFile on failure;
mirror the bounds-check pattern used in normalizeInterpreter() and use that
validated range when accessing Elf64_Phdr so the code never slices past
self.data.items when reading phdr, updating references to ehdr.e_phoff,
ehdr.e_phnum, phdr_size, and the bytesAsValue call accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c675fdf6-98ac-4bc5-a612-cdc371db7ecd

📥 Commits

Reviewing files that changed from the base of the PR and between c2c4689 and e127b7c.

📒 Files selected for processing (2)
  • src/elf.zig
  • test/bundler/bun-build-compile.test.ts

Comment thread src/elf.zig
Comment on lines 184 to 200
const phdr_size = @sizeOf(Elf64_Phdr);
var rw_phdr_index: ?usize = null;
var rw_phdr: Elf64_Phdr = undefined;
var max_vaddr_end: u64 = 0;
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;
}
if (phdr.p_type != elf.PT_LOAD) continue;

const vaddr_end = phdr.p_vaddr + phdr.p_memsz;
if (vaddr_end > max_vaddr_end) max_vaddr_end = vaddr_end;

if ((phdr.p_flags & elf.PF_W) != 0 and rw_phdr_index == null) {
rw_phdr_index = i;
rw_phdr = phdr;
}
}

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 | ⚡ Quick win

Validate the program-header table before reading Elf64_Phdr entries.

writeBunSection() now dereferences e_phoff/e_phnum directly, but unlike normalizeInterpreter() there is no upfront bounds check here. A truncated or corrupt --compile-executable-path ELF will panic on self.data.items[phdr_offset..][0..phdr_size] instead of returning error.InvalidElfFile.

Suggested fix
         const phdr_size = `@sizeOf`(Elf64_Phdr);
+        const phdr_table_end = `@as`(u64, ehdr.e_phoff) +| `@as`(u64, ehdr.e_phnum) *| `@as`(u64, phdr_size);
+        if (phdr_table_end > self.data.items.len) return error.InvalidElfFile;
+
         var rw_phdr_index: ?usize = null;
         var rw_phdr: Elf64_Phdr = undefined;
         var max_vaddr_end: u64 = 0;
         for (0..ehdr.e_phnum) |i| {
📝 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
const phdr_size = @sizeOf(Elf64_Phdr);
var rw_phdr_index: ?usize = null;
var rw_phdr: Elf64_Phdr = undefined;
var max_vaddr_end: u64 = 0;
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;
}
if (phdr.p_type != elf.PT_LOAD) continue;
const vaddr_end = phdr.p_vaddr + phdr.p_memsz;
if (vaddr_end > max_vaddr_end) max_vaddr_end = vaddr_end;
if ((phdr.p_flags & elf.PF_W) != 0 and rw_phdr_index == null) {
rw_phdr_index = i;
rw_phdr = phdr;
}
}
const phdr_size = `@sizeOf`(Elf64_Phdr);
const phdr_table_end = `@as`(u64, ehdr.e_phoff) +| `@as`(u64, ehdr.e_phnum) *| `@as`(u64, phdr_size);
if (phdr_table_end > self.data.items.len) return error.InvalidElfFile;
var rw_phdr_index: ?usize = null;
var rw_phdr: Elf64_Phdr = undefined;
var max_vaddr_end: u64 = 0;
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) continue;
const vaddr_end = phdr.p_vaddr + phdr.p_memsz;
if (vaddr_end > max_vaddr_end) max_vaddr_end = vaddr_end;
if ((phdr.p_flags & elf.PF_W) != 0 and rw_phdr_index == null) {
rw_phdr_index = i;
rw_phdr = phdr;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/elf.zig` around lines 184 - 200, writeBunSection() currently reads
Elf64_Phdr entries using e_phoff/e_phnum without validating the program-header
table bounds, which can panic on truncated data; before the loop that computes
phdr_offset and bytesAsValue, validate that e_phoff and e_phnum define a
contiguous region inside self.data.items (e.g., check `@intCast`(usize,
ehdr.e_phoff) + ehdr.e_phnum * phdr_size <= self.data.items.len) and return
error.InvalidElfFile on failure; mirror the bounds-check pattern used in
normalizeInterpreter() and use that validated range when accessing Elf64_Phdr so
the code never slices past self.data.items when reading phdr, updating
references to ehdr.e_phoff, ehdr.e_phnum, phdr_size, and the bytesAsValue call
accordingly.

Comment thread src/elf.zig
Comment on lines +196 to +219
if ((phdr.p_flags & elf.PF_W) != 0 and rw_phdr_index == null) {
rw_phdr_index = i;
rw_phdr = phdr;
}
}

// The new segment's virtual address: after all existing mappings, page-aligned
const rw_index = rw_phdr_index orelse return error.NoWritableLoadSegment;

// Place the new data at a page-aligned virtual address past every
// existing mapping. page_size is ≥ 128 so this also guarantees the
// 128-byte alignment that JSC's bytecode cache requires — see
// `target_mod = 120` in StandaloneModuleGraph.zig, which assumes the
// payload starts on a 128-byte boundary so bytecode at payload-offset
// 120 lands 128-aligned once the 8-byte `[u64 size]` header is
// accounted for. A non-page-aligned `new_vaddr` (e.g. one inheriting
// `rw_phdr.p_vaddr`'s residue mod 128) would SIGSEGV in JSC bytecode
// deserialization on aarch64.
//
// `new_file_offset` follows the segment's existing (vaddr - offset)
// delta, so the kernel's mmap at `rw_phdr.p_offset → rw_phdr.p_vaddr`
// covers our new payload continuously once we grow p_filesz.
const new_vaddr = alignUp(max_vaddr_end, page_size);
const offset_in_segment = new_vaddr - rw_phdr.p_vaddr;
const new_file_offset = rw_phdr.p_offset + offset_in_segment;

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 | ⚡ Quick win

Select the PT_LOAD that actually owns .bun, not just the first writable one.

Line 196 stops on the first PF_W load, but the relocation math below assumes that this is the segment containing .bun. If the input ELF ever has multiple writable PT_LOADs, new_file_offset and the extended p_filesz/p_memsz are computed from the wrong base while the real .bun segment stays untouched.

Suggested fix
         const phdr_size = `@sizeOf`(Elf64_Phdr);
+        const bun_shdr = self.readShdr(ehdr.e_shoff, bun_section.section_index);
         var rw_phdr_index: ?usize = null;
         var rw_phdr: Elf64_Phdr = undefined;
         var max_vaddr_end: u64 = 0;
         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) continue;

             const vaddr_end = phdr.p_vaddr + phdr.p_memsz;
             if (vaddr_end > max_vaddr_end) max_vaddr_end = vaddr_end;

-            if ((phdr.p_flags & elf.PF_W) != 0 and rw_phdr_index == null) {
+            const contains_bun =
+                bun_shdr.sh_offset >= phdr.p_offset and
+                bun_shdr.sh_offset < phdr.p_offset + phdr.p_filesz;
+            if ((phdr.p_flags & elf.PF_W) != 0 and contains_bun) {
                 rw_phdr_index = i;
                 rw_phdr = phdr;
             }
         }

Also applies to: 337-349

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

In `@src/elf.zig` around lines 196 - 219, The code currently picks the first
writable PT_LOAD via rw_phdr_index/rw_phdr but must pick the writable PT_LOAD
that actually contains the .bun payload; change the selection to scan program
headers for a PT_LOAD with PF_W whose file-range (p_offset .. p_offset +
p_filesz) covers the .bun file offset (or start of the payload) and use that
header for computations of new_vaddr, offset_in_segment, new_file_offset and for
updating p_filesz/p_memsz; apply the same fix where rw_phdr is chosen again (the
analogous block around the 337-349 region) so all relocation math uses the
segment that actually owns .bun rather than the first writable segment.

@claude claude 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.

Thanks for addressing the nit. This is a non-trivial rewrite of the ELF segment layout logic that affects every bun build --compile output on Linux, and it already needed one follow-up (e127b7c) after the c2c4689 build segfaulted compiled binaries on aarch64 — worth a human pass on the PT_LOAD extension invariants and a green CI run on e127b7c before merging.

Extended reasoning...

Overview

Rewrites writeBunSection in src/elf.zig to grow the existing writable PT_LOAD segment to cover the appended .bun payload, instead of repurposing PT_GNU_STACK into a 4th PT_LOAD (which WSL1's kernel loader rejects with ENOEXEC). This entails relocating the entire non-ALLOC tail (.comment/.symtab/.strtab/.shstrtab/debug info + section header table) past the new payload, zero-filling the now-file-backed BSS gap, and patching every moved sh_offset. Also adds a structural ELF-shape regression test in test/bundler/bun-build-compile.test.ts.

Security risks

None identified. The input ELF is bun's own binary (trusted), not user-supplied. New error returns (NoWritableLoadSegment, NewVaddrCollides, InvalidElfFile) guard against malformed input rather than introducing new attack surface.

Level of scrutiny

High. This is low-level binary-format manipulation on the hot path of every Linux --compile output. The logic depends on subtle ELF invariants — p_vaddr ≡ p_offset (mod p_align), BSS zero-fill semantics when p_filesz grows to equal p_memsz, and the implicit assumption that the writable PT_LOAD is the highest-vaddr segment (true for lld's default layout of bun, but not enforced; if another segment sat above it the grown RW p_memsz would overlap it). The first revision (c2c4689) segfaulted compiled binaries across multiple aarch64 CI suites (bundler_compile, express-bun-build-compile, bundler_compile_autoload, bun-options), fixed in e127b7c by page-aligning new_vaddr for JSC's 128-byte bytecode alignment requirement — which underlines how easy it is to get this wrong.

Other factors

  • The robobun CI status comment still reflects c2c4689's failures; there's no visible green run for e127b7c yet.
  • My earlier nit (stale test comment) was addressed.
  • The new test gates the structural shape well, and the added bunAddr % 128n assertion now covers the aarch64 alignment requirement, but a human should sanity-check the tail-relocation / sh_offset rewrite against debug builds and confirm CI is green on the latest commit.

@Jarred-Sumner
Jarred-Sumner merged commit 42fa83c into main Apr 30, 2026
76 of 77 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/7b28e65d/fix-compile-wsl1-enoexec branch April 30, 2026 23:22
@terlar terlar mentioned this pull request May 8, 2026
13 tasks
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.
robobun added a commit that referenced this pull request May 29, 2026
Fixes #31023.

On NixOS, `autoPatchelfHook` runs `patchelf --set-interpreter` on
the packaged bun binary. Patchelf inserts a *new* writable PT_LOAD at
the front of the program-header table to hold the relocated PHDR +
`.interp`, so the template bun ends up with TWO writable PT_LOADs:

  1. patchelf's small `PT_LOAD` at vaddr ~0x1fe000, filesz ~0x290
  2. bun's real RW `PT_LOAD` at vaddr ~0x5c95910 (the one with .bun)

`write_bun_section` picked the FIRST writable PT_LOAD and extended it
to cover the appended .bun payload at vaddr ~0x5e88000. That produced
a giant ~96 MB segment whose file/memory range overlaps the read-only
and executable PT_LOADs at conflicting vaddrs. The kernel ELF loader
mmap'd garbage over .bun's runtime address and `./compiled-output`
segfaulted on `execve`.

Regressed in 1.3.14 via #29967 (the fix for the WSL1 ENOEXEC, #29963),
which switched from repurposing PT_GNU_STACK to extending an existing
writable PT_LOAD.

Pick the writable PT_LOAD by vaddr containment of .bun's `sh_addr`
rather than by order in the phdr table. On a stock (non-patched) bun
that's still the only writable PT_LOAD — same behavior. On a patchelf'd
bun it's the correct (later) segment, which grows cleanly without
overlapping anything.

Tested by simulating `autoPatchelfHook` (`patchelf --set-interpreter`
on the freshly-built bun) and driving `bun build --compile` through
`--compile-executable-path`. Before the fix the compiled binary's
first RW PT_LOAD overlaps every other PT_LOAD; after the fix the only
grown segment is the one already containing .bun and the binary runs.
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.

bun build --compile Linux outputs fail on WSL1 with ENOEXEC after .bun PT_LOAD change

2 participants