compile: extend existing writable PT_LOAD instead of repurposing PT_GNU_STACK#29967
Conversation
…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.
|
Updated 11:54 PM PT - Apr 29th, 2026
❌ @robobun, your commit e127b7c has 1 failures in 🧪 To try this PR locally: bunx bun-pr 29967That installs a local version of the PR into your bun-29967 --bun |
WalkthroughReworks 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
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ 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 |
There was a problem hiding this comment.
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
memmovefor overlapping copies, and explicit alignment math that preserves the ELFp_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.
…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.
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/elf.zigtest/bundler/bun-build-compile.test.ts
| 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 % 128nassertion now covers the aarch64 alignment requirement, but a human should sanity-check the tail-relocation /sh_offsetrewrite against debug builds and confirm CI is green on the latest commit.
…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.
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.
Fixes #29963.
Reproduction
On WSL1, Linux executables produced by
bun build --compilesince 1.3.13 fail at kernelexecvewithENOEXEC (Exec format error)before any userland code runs: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'sp_typefield fromPT_LOADback toPT_GNU_STACKand confirmed WSL1 then accepts the binary — proving the trigger is specifically the latePT_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: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.zigwriteBunSection:PF_WPT_LOAD (where.bunalready lives as a BlobHeader-aligned PROGBITS section).rw.p_offset + alignUp(rw.p_memsz, rw.p_align)in file and the matching vaddr — the equal offsets automatically satisfyp_vaddr mod p_align == p_offset mod p_align, the ELF constraint for extending a PT_LOAD.p_fileszandp_memszto cover the new tail. Equal values are fine — the extension is entirely file-backed, no new BSS gap..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.sh_offset.Verification
On this (non-WSL1) Linux host:
The
.bunvaddr (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.bunatexecve, no/proc/self/exeread needed.Test (
test/bundler/bun-build-compile.test.ts): new test walks the compiled ELF's program headers and assertsPT_GNU_STACKis 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
.bunELF section approach (replaces/proc/self/exetrailer read; still preserved here).bun build --compileon NixOS uses store path forld-linux-x86-64.so.2#24742,bun build --compile-produced executable does not run in NixOS (bun 1.3.12) #29290 — PT_INTERP normalization for Nix/Guix hosts. Untouched.