Orbit C++ → Rust · Post 03

The Stage That Didn't Ship

Unwind ranges landed. Line info passed every existing test, then failed against 4,719 real binaries four separate times — and the fourth one is why it is still delegating to C++.

16.3MSymbols compared
0Structural mismatches
4Corpus-only divergences
14 / 3Ported / delegating
1Stage held back
0Tests edited, still

The plan calls the differential corpus "gate 2" and justifies it in one sentence: the curated testdata is a dozen small binaries chosen to exercise specific code paths, and a real libQt5Core.so exercises combinations nobody wrote a test for. This post is that sentence being cashed. Four divergences, none of them reachable from the 34 test files, and the last one stopped a stage from shipping.

Stage 2c: unwind ranges

For a stripped binary the unwind tables are the only remaining record of where functions begin and end, so Orbit synthesises a symbol per Frame Descriptor Entry, named [function@0x…]. Ported to gimli; four Rust tests reproducing the C++ assertions, including the exact FDE ordering, passed immediately.

One thing got simpler. The C++ carries a hand-written fixup for DW_EH_PE_pcrel pointers, guarded by a TODO noting it stopped being necessary in LLVM 13 — the pin is 17, so it is dead weight nobody removed. gimli resolves those pointers itself once given the section's load address, so there is no counterpart.

Then the corpus ran, and found two things.

14 · corpus

A section with CIEs and no FDEs

llvm::DWARFDebugFrame::empty() reports on entries, not on FDEs. A .debug_frame holding only Common Information Entries is therefore "found, and describes no ranges" — which produces a different error message from "not found". I was falling through to the next section and reporting the wrong one.

Two error strings that differ by a clause, on a file nobody would think to construct.

15 · corpus

An .eh_frame that is four zero bytes

libabsl_random_internal_platform.so's entire .eh_frame is a bare zero terminator. gimli reads that as end-of-entries and yields nothing. LLVM does not stop there — it constructs a terminator CIE and keeps it, so empty() is still false.

The fix is a byte-length test, which felt wrong until I worked out why it is exactly right: LLVM produces at least the terminator entry for any section it can read an initial length from, and below four bytes it cannot read one at all. So the threshold is not a heuristic, it is where LLVM's parser stops.

With both fixed, the corpus went clean, and the coverage number moved a long way — unwind ranges are numerous:

elf files seen            4719
symbols compared      16293491
structural disagreements     0

Stage 2d: line info, which passed every test and shipped nothing

addr2line made GetLineInfo look easy. Three Rust tests, mirroring RunLineInfoTest across both files the C++ suite uses — 0x1140 is /ssd/local/hello.cpp line 3, 0x1150 is line 4, 0x10 is an error. All green. All 68 ObjectUtilsTests cases green in all three modes.

Then the corpus.

every unit test green · then 4,719 real binaries round 1 fixed Address inside no subprogram DIE find_frames walks .debug_info and yields nothing; the line table still covers it. LLVM reports that as its base frame. Added the fallback. round 2 counted LLVM falls back to STT_FILE symbols "crtstuff.c" line 0 for deregister_tm_clones — a name from .symtab, not from DWARF. A file with no line number is not a location. Counted, not fatal. round 3 fixed SHF_COMPRESSED debug sections libc.debug compresses .debug_info. Raw bytes to gimli = silence, not an error. The same zlib problem the Bazel port hit from the other side. Fix shipped. round 4 stopped the stage The two implementations spell the path differently llvm sigaction.c addr2line ../sysdeps/unix/sysv/linux/x86_64/sigaction.c Different rules for joining comp_dir, the directory entry and the file name. Rounds 1–3 were my bugs or reconcilable conventions. Round 4 is two correct implementations of an underspecified thing.
Each round took one corpus run to surface and named the exact file and address. Nothing here is reachable from the curated testdata: the compressed sections, the CRT stub with no line-table row, and the glibc path spelling all live in binaries that were on the machine anyway.

Why round 4 stopped it

I could have written a path normaliser. I wrote one for the demangler two posts ago and it worked. The difference is what the string is for.

A demangled name is a rendering — it is shown to a person, and the port's promise is that it produces a demangling, not LLVM's exact text. A source path is not a rendering. It is consumed by machinery: symbol upload, source-file lookup, the code that decides whether a file on disk is the one the binary was built from. "Close enough" there is not a judgement the porter gets to make on the team's behalf.

So GetLineInfo still delegates. The Rust implementation and its tests stay in the tree, because the remaining work is a comparison against getFileNameByIndex's join rules, not a rewrite. The delegation counter did not move: still three, all of them line info.

What did ship from stage 2d

Decompression. sections.rs now inflates SHF_COMPRESSED sections before parsing, and that is load-bearing for unwind ranges too — a compressed .debug_frame was reading as empty. Finding it took a stage that otherwise produced nothing, which is a reasonable trade.

The uncomfortable part

Stage 2d passed every test in the repository. All 68 ObjectUtilsTests cases, in all three backend modes, plus three new Rust tests written specifically for it. A port that ran the test suite and stopped would have shipped it, and the bug — silently empty line info for every binary with compressed debug sections, which on Ubuntu is most of them with debug info at all — would have surfaced as "symbols look wrong sometimes" weeks later.

That is the entire argument for gate 2, and I did not expect to be able to demonstrate it this cleanly. The corpus costs about ninety seconds to run and needed no test data anyone had to write.

Worth being honest about

Three of the four rounds were my bugs, not gimli's or addr2line's. The crates were fine. What the corpus was actually testing was whether I had understood what the C++ does, and three times out of four I had not — despite having read the function carefully enough to write a faithful-looking port. Reading is not verification.

Where things stand

MeasureBaselineNowReading
Symbols compared, structurally identical—16,293,491Across 4,709 real binaries, zero mismatches
ElfFile methods on Rust014 of 17Three delegate, all line info
Files including an llvm/ header119Unchanged this post
External Rust crates011object, gimli, addr2line and their trees, including flate2 for decompression
deps(//src/ObjectUtils)5,8438,418Still worse, still waiting on stage 2h
C++ tests edited00The rule that makes any of the above mean something

Next

Two things stand between here and deleting //bazel/deps:llvm, which still has exactly one dependent. CoffFile — 875 lines of PE/COFF with 17 test cases — and PdbFileLlvm, which the plan already flagged as the long pole because Orbit reads PDBs on Linux for Wine and Proton modules.

And line info, whenever someone works out the path rules. It is the only thing in ElfFile that a Rust implementation has been written for and then not shipped.