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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions changelog.d/8948-gc-map-keeps-personality-directives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Fixed a Linux crash on the first caught `throw` in any program whose
`try`/`catch` spans more than one module or codegen unit: the process died
with a general-protection fault inside `_Unwind_RaiseException` (`call *%rax`
with a garbage `rax`) during module init. Coop's Next.js dylib hit it on
every start on x86-64 Linux; a two-module `.ts` program with one `try` in
each module reproduces it as a plain executable.

Root cause is in the compact GC-map rewrite (`gc_map.rs`), not in the linker
or the personality routine. `compact_stack_map_asm` treats every line from
the `.llvm_stackmaps` section switch up to the next section switch as the
stack map and replaces it. LLVM's `AsmPrinter` finalization prints the ELF
personality slot's attributes — `.hidden DW.ref.perry_eh_personality` and
`.weak DW.ref.perry_eh_personality` — right after the stack map and *before*
`.section .data.DW.ref.perry_eh_personality,"awG",…,comdat`, so both lines
fell inside the replaced range and were dropped. The assembler then defined
the COMDAT slot as a LOCAL symbol (`readelf -Ws` on any cached `.o`:
`OBJECT LOCAL DEFAULT DW.ref.perry_eh_personality`, where clang on the same
IR gives `OBJECT WEAK HIDDEN`).

That is fatal at every multi-object link. GNU ld keeps one COMDAT group per
program (also in the `ld -r` merge of split codegen units) and, because a
reference to a symbol in a discarded group is only redirected for *global*
symbols, the other objects' CIE personality relocations resolve to nothing —
silently, since `.eh_frame` is exempt from the "defined in discarded section"
diagnostic. `readelf --debug-dump=frames` on the linked image shows one CIE
with a real `DW_EH_PE_indirect|pcrel|sdata4` personality and every other
`zPLR` CIE carrying junk (`9b 18 00 00 00 …`, `9b 00 00 00 00 …`). The
unwinder decodes that junk as the personality pointer for any frame owned by
those objects and calls it. Mach-O never had the problem (no `DW.ref` slot,
no COMDAT), which is why the macOS arms and single-file gap tests stayed
green.

The rewrite now carries every zero-width line inside the block that does not
name `__LLVM_StackMaps` through verbatim, in its original position: symbol
attributes LLVM printed ahead of a section switch, and the `-O3`
absolute-symbol assignments (`perry_null_guard_zero = …`) that were parsed
as zero bytes and then lost the same way. Unit tests pin the x86-64 ELF
shape (attributes re-emitted exactly once, before the slot's section; the
map label's own attributes still dropped; assignments re-emitted).

Verified on Ubuntu 24.04 / x86-64 / LLVM 22.1.8 / binutils 2.42: the
two-module reproducer segfaulted before and prints its result after; the
rebuilt objects carry `WEAK HIDDEN DW.ref.perry_eh_personality`; Coop's
75 MB Next.js App Route dylib (split codegen units merged with `ld -r`)
initialises and serves `200 OK` in the daemon, one app and three apps.
178 changes: 178 additions & 0 deletions crates/perry-codegen/src/gc_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,23 @@ struct RawBlock {
end_line: usize,
bytes: Vec<u8>,
symbols: HashMap<usize, String>,
/// Zero-width lines found inside the block that describe some OTHER
/// symbol — they must be re-emitted, not dropped with the map bytes.
///
/// The block is delimited by section switches, but LLVM prints a
/// symbol's *attributes* before it switches to that symbol's section.
/// The ELF personality slot is the case that bit: `AsmPrinter`
/// finalization emits the stack map, then `.hidden` + `.weak` for
/// `DW.ref.perry_eh_personality`, and only then `.section
/// .data.DW.ref.perry_eh_personality,"awG",…,comdat`. Swallowing those
/// two lines assembles the COMDAT slot as a LOCAL symbol; every
/// multi-object link (`ld -r` of split codegen units, or the final
/// exe/dylib link) keeps one group and silently drops the other objects'
/// CIE personality relocations (`.eh_frame` is exempt from the
/// discarded-section diagnostic), so the first caught throw through a
/// frame from any other object calls a garbage personality pointer and
/// dies in `_Unwind_RaiseException`.
carried: Vec<String>,
}

fn find_block_start(lines: &[&str]) -> Option<usize> {
Expand All @@ -261,6 +278,7 @@ fn parse_block(lines: &[&str], word_width: usize) -> Result<RawBlock, String> {

let mut bytes: Vec<u8> = Vec::new();
let mut symbols: HashMap<usize, String> = HashMap::new();
let mut carried: Vec<String> = Vec::new();
let mut end_line = lines.len();

for (index, raw) in lines.iter().enumerate().skip(start_line + 1) {
Expand Down Expand Up @@ -299,6 +317,7 @@ fn parse_block(lines: &[&str], word_width: usize) -> Result<RawBlock, String> {
// asm printer emits them in this form. Mach-O output does not, so this
// is invisible on the macOS arms.
if is_symbol_assignment(line) {
carry_if_foreign(&mut carried, line);
continue;
}

Expand Down Expand Up @@ -373,16 +392,30 @@ fn parse_block(lines: &[&str], word_width: usize) -> Result<RawBlock, String> {
index + 1
));
}
carry_if_foreign(&mut carried, line);
}

Ok(RawBlock {
start_line,
end_line,
bytes,
symbols,
carried,
})
}

/// A zero-width line inside the block emits no map bytes, so it can only be
/// describing a symbol. If that symbol is the map's own label it belongs to
/// the block being replaced (the replacement declares its own); anything
/// else — a symbol attribute LLVM printed ahead of its section switch, or an
/// absolute-symbol assignment — is unrelated to the map and must survive the
/// rewrite verbatim.
fn carry_if_foreign(carried: &mut Vec<String>, line: &str) {
if !line.contains("__LLVM_StackMaps") {
carried.push(line.to_string());
}
}

fn parse_int(text: &str) -> Option<u64> {
let text = text.trim();
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
Expand Down Expand Up @@ -1058,6 +1091,17 @@ fn compact_stack_map_asm(asm: &str, target: &str) -> Result<Option<(String, GcMa
}
}
out.push_str(&replacement);
// Re-emit the symbol lines the block swallowed, in their original order,
// exactly where they stood: after the map, before the section switch
// that ends the block. They are position-independent (attributes and
// assignments), so the only thing that matters is that they are present.
for line in &block.carried {
if !is_symbol_assignment(line) {
out.push('\t');
}
out.push_str(line);
out.push('\n');
}
for line in &lines[block.end_line..] {
if line.contains("__LLVM_StackMaps") {
continue;
Expand Down Expand Up @@ -1644,6 +1688,140 @@ mod tests {
assert_eq!(block.bytes.len(), 8);
}

/// The x86-64 **ELF** spelling of `sample_asm`, with `tail` inserted
/// between the map's last byte and the section switch that ends the
/// block — the exact spot where `AsmPrinter` finalization prints the
/// attributes of the NEXT symbol it is about to define.
fn x86_64_elf_sample_asm(tail: &str) -> String {
let mut asm = String::new();
asm.push_str("\t.section\t.llvm_stackmaps,\"a\",@progbits\n");
asm.push_str("\t.p2align\t3, 0x0\n");
asm.push_str("__LLVM_StackMaps:\n");
asm.push_str("\t.byte\t3\n\t.byte\t0\n\t.short\t0\n");
asm.push_str("\t.long\t1\n"); // functions
asm.push_str("\t.long\t0\n"); // constants
asm.push_str("\t.long\t1\n"); // records
asm.push_str("\t.quad\tprobe_fn\n");
asm.push_str("\t.quad\t144\n"); // stack size
asm.push_str("\t.quad\t1\n"); // record count
asm.push_str("\t.quad\t0\n"); // patchpoint id
asm.push_str("\t.long\t64\n"); // instruction offset
asm.push_str("\t.short\t0\n");
asm.push_str("\t.short\t4\n"); // location count
for _ in 0..3 {
asm.push_str(
"\t.byte\t4\n\t.byte\t0\n\t.short\t8\n\t.short\t0\n\t.short\t0\n\t.long\t0\n",
);
}
// The live root: RBP-relative (DWARF 6), frame offset -24.
asm.push_str(
"\t.byte\t3\n\t.byte\t0\n\t.short\t8\n\t.short\t6\n\t.short\t0\n\t.long\t4294967272\n",
);
asm.push_str("\t.p2align\t3, 0x0\n");
asm.push_str("\t.short\t0\n\t.short\t0\n"); // live-out header
asm.push_str("\t.p2align\t3, 0x0\n");
asm.push_str(tail);
asm.push_str("\t.section\t\".note.GNU-stack\",\"\",@progbits\n");
asm
}

/// The Linux crash this guards: LLVM prints `.hidden` + `.weak` for the
/// personality slot `DW.ref.perry_eh_personality` BEFORE switching to
/// its COMDAT section, i.e. inside what this parser treats as the
/// stack-map block. Dropping them assembles the slot as a LOCAL symbol
/// in a COMDAT group; the linker keeps one group per program and drops
/// every other object's CIE personality relocation (`.eh_frame` is
/// exempt from the discarded-section complaint), and the first caught
/// throw through a frame from any other module or codegen unit calls a
/// garbage personality pointer inside `_Unwind_RaiseException`. A
/// two-module program with a `try` in each module is enough to hit it.
#[test]
fn elf_personality_slot_attributes_survive_the_rewrite() {
let asm = x86_64_elf_sample_asm(concat!(
"\t.hidden\tDW.ref.perry_eh_personality\n",
"\t.weak\tDW.ref.perry_eh_personality\n",
"\t.section\t.data.DW.ref.perry_eh_personality,\"awG\",@progbits,DW.ref.perry_eh_personality,comdat\n",
"\t.p2align\t3, 0x0\n",
"\t.type\tDW.ref.perry_eh_personality,@object\n",
"\t.size\tDW.ref.perry_eh_personality, 8\n",
"DW.ref.perry_eh_personality:\n",
"\t.quad\tperry_eh_personality\n",
));
let (out, stats) = compact_stack_map_asm(&asm, "x86_64-unknown-linux-gnu")
.expect("an x86-64 ELF stack map must parse")
.expect("an x86-64 ELF stack map must be rewritten");
assert_eq!(stats.functions, 1);
assert_eq!(stats.roots, 1);
assert!(!out.contains("__LLVM_StackMaps"), "{out}");
assert_eq!(
out.matches("\t.hidden\tDW.ref.perry_eh_personality\n")
.count(),
1,
"the slot's visibility must be re-emitted exactly once:\n{out}"
);
assert_eq!(
out.matches("\t.weak\tDW.ref.perry_eh_personality\n")
.count(),
1,
"the slot's weak binding must be re-emitted exactly once:\n{out}"
);
// Order: map, then the attributes, then the slot's own section — the
// layout LLVM printed, so the assembler sees exactly what it would
// have seen without the rewrite.
let map = out.find("_perry_gc_map:").expect("compact map label");
let hidden = out.find("\t.hidden\tDW.ref").expect("hidden directive");
let weak = out.find("\t.weak\tDW.ref").expect("weak directive");
let section = out
.find("\t.section\t.data.DW.ref.perry_eh_personality")
.expect("slot section");
assert!(map < hidden && hidden < weak && weak < section, "{out}");
assert!(
out.contains("\t.type\tDW.ref.perry_eh_personality,@object\n"),
"{out}"
);
}

/// Only lines about OTHER symbols are carried: the map's own label is
/// re-declared by the replacement, so anything naming it stays dropped.
#[test]
fn the_map_labels_own_attributes_are_not_carried() {
let asm = x86_64_elf_sample_asm(concat!(
"\t.globl\t__LLVM_StackMaps\n",
"\t.type\t__LLVM_StackMaps,@object\n",
"\t.size\t__LLVM_StackMaps, .-__LLVM_StackMaps\n",
));
let (out, _) = compact_stack_map_asm(&asm, "x86_64-unknown-linux-gnu")
.expect("an x86-64 ELF stack map must parse")
.expect("an x86-64 ELF stack map must be rewritten");
assert!(!out.contains("__LLVM_StackMaps"), "{out}");
assert!(out.contains("_perry_gc_map:"), "{out}");
}

/// The -O3 ELF absolute-symbol aliases land inside the block too. They
/// define symbols the code references, so they must survive the rewrite
/// as well as parse to zero bytes.
#[test]
fn symbol_assignments_inside_the_block_are_re_emitted() {
let asm = x86_64_elf_sample_asm(concat!(
"perry_null_guard_zero = 0\n",
".Lperry_ic_8 = .Ltmp3-4\n",
));
let (out, stats) = compact_stack_map_asm(&asm, "x86_64-unknown-linux-gnu")
.expect("an x86-64 ELF stack map must parse")
.expect("an x86-64 ELF stack map must be rewritten");
assert_eq!(stats.roots, 1);
assert_eq!(
out.matches("\nperry_null_guard_zero = 0\n").count(),
1,
"{out}"
);
assert_eq!(
out.matches("\n.Lperry_ic_8 = .Ltmp3-4\n").count(),
1,
"{out}"
);
}

#[test]
fn trailing_llvm_buffer_nul_is_not_an_assembly_directive() {
let asm = concat!(
Expand Down
Loading