Skip to content

fix(codegen): keep the personality slot's .hidden/.weak through the compact GC-map rewrite (Linux throw/catch GPF across modules) - #8948

Merged
proggeramlug merged 2 commits into
mainfrom
fix/gc-map-keeps-personality-directives
Aug 28, 2026
Merged

fix(codegen): keep the personality slot's .hidden/.weak through the compact GC-map rewrite (Linux throw/catch GPF across modules)#8948
proggeramlug merged 2 commits into
mainfrom
fix/gc-map-keeps-personality-directives

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Symptom

On x86-64 Linux, Coop's Next.js App Route dylib (a 75 MB --output-type dylib image, split codegen units merged with ld -r) dies during module init on the first caught throw:

Thread 21 "coop-app-next-b" received signal SIGSEGV
#0 _Unwind_RaiseException () from libgcc_s.so.1        rip = _Unwind_RaiseException+341
#1 js_throw () from libperry_runtime.so
#2 perry_closure_node_modules_next_dist_server_require_hook_js () from app.so
#3 js_closure_call1_receiverless () from libperry_runtime.so
#4 perry_closure_node_modules_next_dist_server_require_hook_js () from app.so
…
#8 js_run_module_init_catching () from libperry_runtime.so

The same fixture at the same commit initialises and serves on macOS. It is not dylib- or Coop-specific: a two-module executable with one try in each module segfaults the same way (see reproducer).

Mechanism (gdb evidence)

The faulting instruction is the personality call in phase 1 of the unwinder, with a garbage function pointer:

=> 0x7ffff7fa8035 <_Unwind_RaiseException+341>:  call   *%rax
rax            0x257b0400005452ff
rdx            0x50455252594a5300     ("PERRYJS\0", the exception class)

cur_context (x/28gx $rbx) shows the frame being processed is frame #4 (ra=0x7fffa6c89e18, func=perry_closure_…require_hook_js, lsda in .gcc_except_table), so the FDE/LSDA lookup worked and the CIE's personality pointer decoded to junk. readelf --debug-dump=frames app.so confirms it — 60 CIEs, one per merged object, and only the first carries a real DW_EH_PE_indirect|pcrel|sdata4 pointer to DW.ref.perry_eh_personality:

Augmentation: "zPLR"   Augmentation data: 9b 61 e2 19 00 1b 1b   <- resolves to DW.ref.perry_eh_personality (ok)
Augmentation: "zPLR"   Augmentation data: 9b 18 00 00 00 1b 1b   <- junk
Augmentation: "zPLR"   Augmentation data: 9b 30 00 00 00 1b 1b   <- junk
Augmentation: "zPLR"   Augmentation data: 9b 4c 00 00 00 1b 1b   <- junk
…

Walking that back through the objects: every Perry-emitted .o (object cache) defines the slot as

OBJECT  LOCAL  DEFAULT   DW.ref.perry_eh_personality      (section .data.DW.ref.perry_eh_personality, "awG" COMDAT)

whereas clang-22 -S on the same .ll defines it OBJECT WEAK HIDDEN. A LOCAL symbol inside a COMDAT group is fatal at any multi-object link: GNU ld keeps one group per program (also in the ld -r merge of split codegen units) and only redirects references to global symbols of a discarded group to the kept copy; references to a local one are dropped — silently, because .eh_frame is exempt from the "defined in discarded section" complaint (_bfd_elf_default_action_discarded). The dropped relocation leaves the pcrel field with whatever bytes were there, which the unwinder then decodes as the personality address.

The reason the symbol is LOCAL is Perry's own assembly rewrite in crates/perry-codegen/src/gc_map.rs (compact_stack_map_asm, the statepoint compact GC-map pass, on by default with RS4GC). It treats every line from the .llvm_stackmaps section switch to the next section switch as the stack map and replaces the range. LLVM's AsmPrinter finalization prints the personality slot's attributes before switching to the slot's section:

	.hidden	DW.ref.perry_eh_personality        <- inside the replaced range
	.weak	DW.ref.perry_eh_personality         <- inside the replaced range
	.section	.data.DW.ref.perry_eh_personality,"awG",@progbits,DW.ref.perry_eh_personality,comdat
	.type	DW.ref.perry_eh_personality,@object
	.size	DW.ref.perry_eh_personality, 8
DW.ref.perry_eh_personality:
	.quad	perry_eh_personality

Captured from Perry's in-process pipeline (.o.s scratch file, kept with a small unlink shim): the emitted text has the .section/.type/.size/label lines and no .hidden/.weak. Without them the assembler binds the group signature symbol locally. Mach-O has no DW.ref slot and no COMDAT groups (the personality goes through a GOT entry), so macOS never sees it; single-file programs never link two groups, so the Linux gap suite never sees it either.

Minimal linker-level demonstration of the same effect, no Perry involved:

clang-22 -fPIC -c a.cpp b.cpp        # each with a cleanup -> DW.ref.__gxx_personality_v0 (WEAK HIDDEN)
objcopy --localize-hidden a.o al.o; objcopy --localize-hidden b.o bl.o
ld -r -o abl.o al.o bl.o
readelf --debug-dump=frames abl.o | grep "Augmentation data"
#   9b ed ff ff ff 1b 1b      first CIE: relocated
#   9b 00 00 00 00 1b 1b      second CIE: reference to the discarded group's local symbol dropped

Minimal reproducer (Perry)

// other.ts
export function f(): number { try { throw new Error("in f"); } catch (e) { return 2; } }
export function g(x: number): number { try { if (x > 0) throw new Error("in g"); return 0; } catch (e) { return 3; } }
// main.ts
import { f, g } from "./other";
function boom(): number { try { throw new Error("boom"); } catch (e) { return 1; } }
const n = boom() + f() + g(1);
console.log("init ok", n);

perry compile main.ts -o main_exe && ./main_exe on Ubuntu 24.04 x86-64 (LLVM 22.1.8, binutils 2.42):

  • before (main @ 924dd16): Segmentation fault (core dumped), gdb: call *%rax with rax = 0x52ef040000006fff; readelf --debug-dump=frames shows a 9b 18 00 00 00 CIE.
  • after (this branch): prints init ok 6, exit 0; the two module CIEs merge into one with a valid pointer; the cached objects carry OBJECT WEAK HIDDEN DW.ref.perry_eh_personality.

PERRY_CODEGEN_UNITS=2 perry compile main.ts --output-type dylib (forces the ld -r split-unit merge) likewise goes from a junk CIE to a single valid zPLR CIE.

Fix

parse_block now records every zero-width line inside the block that does not name __LLVM_StackMaps (symbol attributes 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), and compact_stack_map_asm re-emits them verbatim after the replacement map, before the section switch that ended the block — i.e. exactly where LLVM had them. Lines about the map's own label stay dropped (the replacement declares its own). No change to the map encoding, the personality routine, or the link lines.

Verification

  • cargo test --release -p perry-codegen --lib gc_map (server, ec5a0ac): 23 passed, including the three new tests (elf_personality_slot_attributes_survive_the_rewrite, the_map_labels_own_attributes_are_not_carried, symbol_assignments_inside_the_block_are_re_emitted).
  • cargo test --release -p perry-codegen --lib (server): 1337 passed, 0 failed, 1 ignored.
  • cargo fmt --check -p perry-codegen: clean. cargo clippy -p perry-codegen --all-targets (macOS, LLVM 22): exit 0, no new warnings (the gc_map.rs warnings it prints are pre-existing manual_is_multiple_of hits on untouched lines).
  • Reproducer above, before/after, on the Linux server.
  • Coop end to end on the Linux server (Ubuntu 24.04, 16 cores): providers + ext wrappers rebuilt from ec5a0ac, daemon rebuilt, Next.js fixture recompiled through the daemon (object cache cold for this build id). readelf on the new app.so: exactly one zPLR CIE with a valid personality. Single-app harness (COOP_BENCH_APP_COUNTS=1 … measure_in_process_startup_and_rss): warm bench-000: 200 OK {"runtime":"next","iterations":100,"checksum":3726872593} on all three trials. Three-app harness (COOP_BENCH_APP_COUNTS=3 COOP_BENCH_PRELOAD_CONCURRENCY=1): bench-000/001/002: 200 OK on all three trials (RESULT apps=3 restart_startup_median_ms=1655 ready_rss_median_mib=489.7).

Not covered

  • No end-to-end Linux test in Perry's own suite yet asserts the symbol binding of the assembled object or runs a multi-module throw/catch executable; the regression is pinned at the assembly-rewrite level only. A gap-suite fixture that imports a second module containing a try would close that on the Linux arms.
  • aarch64 Linux was not run (same code path and the same LLVM ordering, so the same fix applies; only x86-64 was measured).
  • Windows/COFF is untouched (no COMDAT-group personality slot in that path).
  • Coop still pins Perry at 77e79b7 in perry-main.lock; it needs a bump to a commit containing this fix (the server's uncommitted lock points at ec5a0ac for the verification above).

https://claude.ai/code/session_01UZJbhb2FTuakurTHPAKQgd

Ralph Küpper added 2 commits August 28, 2026 13:04
…ompact GC-map rewrite

`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
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0fc3447c-c44f-443b-b86e-ee50c84095e8

📥 Commits

Reviewing files that changed from the base of the PR and between 6d10e8a and 3d7fa12.

📒 Files selected for processing (2)
  • changelog.d/8948-gc-map-keeps-personality-directives.md
  • crates/perry-codegen/src/gc_map.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The GC-map rewrite now preserves foreign zero-width assembly lines inside stack-map blocks. It keeps ELF personality-slot attributes and absolute-symbol assignments, drops attributes for the map label, and adds tests for these cases. The changelog records the Linux crash and verification results.

Changes

GC-map assembly preservation

Layer / File(s) Summary
Parse and classify foreign assembly lines
crates/perry-codegen/src/gc_map.rs
RawBlock stores carried lines. parse_block collects foreign symbol assignments and zero-width directives while excluding lines that name __LLVM_StackMaps.
Re-emit and validate preserved lines
crates/perry-codegen/src/gc_map.rs, changelog.d/8948-gc-map-keeps-personality-directives.md
compact_stack_map_asm restores carried lines in their original order. Tests cover ELF personality attributes, map-label filtering, and -O3 assignments. The changelog documents the crash cause, fix, and verification.
Estimated code review effort: 3 (Moderate) ~20 minutes

Merge Risk: 🟡 Moderate · up to 3d7fa

The compiler now preserves assembly directives across a size-changing metadata rewrite. The targeted personality-symbol case is covered, but other accepted directives could change relative or unwind metadata and affect exception handling or garbage collection in linked binaries; this PR is not merge-ready until that bounded correctness risk is addressed or explicitly accepted.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 1 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description gives detailed information about the symptom, mechanism, fix, verification, and known limitations. It omits the template's explicit headings and checklist, but it provides equivalent c…
Linked Issues check ✅ Passed The change is associated with issue #8948 through the changelog filename and the stated PR objective.
Out of Scope Changes check ✅ Passed The changes are limited to the GC-map rewrite and its changelog entry. They directly support the stated Linux exception-unwinding fix.
Title check ✅ Passed The title clearly identifies the preservation of the personality slot's .hidden and .weak directives and the resulting Linux throw/catch crash fix.
Full details: Docstring Coverage

Explanation

Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 1 files. (1 skipped: 1 unsupported.)

Full details: Description check

Explanation

The description gives detailed information about the symptom, mechanism, fix, verification, and known limitations. It omits the template's explicit headings and checklist, but it provides equivalent content for most required sections.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/gc-map-keeps-personality-directives

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged. One fix pushed: this takes gc_map.rs from 1869 to 2047 lines, over the 2000-line cap, so check_file_size.sh was red. Moved the cfg(test) module into gc_map_tests.rs verbatim via the #[path] pattern the tree already uses (1332 + 720 lines), then reformatted.

Validated batched with #8949/#8950/#8951: codegen 1337/0, runtime 2773/0, evac failing set 16 (the pre-existing one), run_lint_gates.sh 57 of 58 including the compile tier — the exception is the pre-existing \${{ }} artifact (#8929).

@proggeramlug
proggeramlug merged commit 21b5810 into main Aug 28, 2026
19 checks passed
@proggeramlug
proggeramlug deleted the fix/gc-map-keeps-personality-directives branch August 28, 2026 11:53
proggeramlug added a commit that referenced this pull request Aug 28, 2026
…cap (#8957)

#8948 took gc_map.rs to 2047 lines and `check_file_size` has been red on
main since it merged. I had prepared this split during that PR's review but
pushed it to the wrong remote — #8948 is same-repo, and the push went to a
fork the PR does not track — so the unfixed head is what merged.

Moved the cfg(test) module out verbatim via the #[path] pattern the tree
already uses (1332 + 712 lines).

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant