From ec5a0acbdad0ceae3defc9cb72b4693ae33b6edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 13:04:49 +0200 Subject: [PATCH 1/2] fix(codegen): keep the personality slot's .hidden/.weak through the compact GC-map rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compact_stack_map_asm` treats every line from the `.llvm_stackmaps` section switch to the next section switch as the stack map, and replaces it wholesale. 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 switching to the slot's `.data.DW.ref.perry_eh_personality,"awG",…,comdat` section, so the rewrite swallowed both lines and the assembler defined the COMDAT slot as a LOCAL symbol. Every multi-object link then breaks the unwind tables: `ld -r` (split codegen units) and the final exe/dylib link keep one COMDAT group and resolve nothing for the other objects' CIE personality relocations — `.eh_frame` is exempt from the discarded-section complaint, so the drop is silent. The first caught `throw` whose unwind crosses a frame from any other module or unit calls a garbage personality pointer, and the process dies with a GPF at `_Unwind_RaiseException`'s `call *%rax`. A two-module program with a `try` in each module is enough; Coop's Next.js dylib hit it on Linux during module init. Mach-O is unaffected (no DW.ref/COMDAT slot). Zero-width lines inside the block that do not name `__LLVM_StackMaps` are now carried through the rewrite verbatim, in their original position: symbol attributes LLVM printed ahead of a section switch, and the -O3 absolute-symbol assignments that were parsed as zero bytes and then lost. Claude-Session: https://claude.ai/code/session_01UZJbhb2FTuakurTHPAKQgd --- crates/perry-codegen/src/gc_map.rs | 178 +++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs index 9b872d66c8..720a6b8233 100644 --- a/crates/perry-codegen/src/gc_map.rs +++ b/crates/perry-codegen/src/gc_map.rs @@ -246,6 +246,23 @@ struct RawBlock { end_line: usize, bytes: Vec, symbols: HashMap, + /// 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, } fn find_block_start(lines: &[&str]) -> Option { @@ -261,6 +278,7 @@ fn parse_block(lines: &[&str], word_width: usize) -> Result { let mut bytes: Vec = Vec::new(); let mut symbols: HashMap = HashMap::new(); + let mut carried: Vec = Vec::new(); let mut end_line = lines.len(); for (index, raw) in lines.iter().enumerate().skip(start_line + 1) { @@ -299,6 +317,7 @@ fn parse_block(lines: &[&str], word_width: usize) -> Result { // 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; } @@ -373,6 +392,7 @@ fn parse_block(lines: &[&str], word_width: usize) -> Result { index + 1 )); } + carry_if_foreign(&mut carried, line); } Ok(RawBlock { @@ -380,9 +400,22 @@ fn parse_block(lines: &[&str], word_width: usize) -> Result { 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, line: &str) { + if !line.contains("__LLVM_StackMaps") { + carried.push(line.to_string()); + } +} + fn parse_int(text: &str) -> Option { let text = text.trim(); if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) { @@ -1058,6 +1091,17 @@ fn compact_stack_map_asm(asm: &str, target: &str) -> Result 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!( From 3d7fa12effd620d9ee1c3e683a10345287fd7961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 13:11:21 +0200 Subject: [PATCH 2/2] docs: changelog fragment for #8948 Claude-Session: https://claude.ai/code/session_01UZJbhb2FTuakurTHPAKQgd --- ...948-gc-map-keeps-personality-directives.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 changelog.d/8948-gc-map-keeps-personality-directives.md diff --git a/changelog.d/8948-gc-map-keeps-personality-directives.md b/changelog.d/8948-gc-map-keeps-personality-directives.md new file mode 100644 index 0000000000..a4f8e0dd5a --- /dev/null +++ b/changelog.d/8948-gc-map-keeps-personality-directives.md @@ -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.