The whole plan rests on one property of this codebase, which I checked before writing anything else: every test in ObjectUtilsTests constructs its object through a factory function and asserts only through an abstract interface. ElfFileImpl lives in an anonymous namespace; no test can see it. That means the existing 659-line ElfFileTest.cpp is already an implementation-independent conformance suite, and a Rust implementation can be dropped behind CreateElfFile() and judged by it without editing a single test.
rust/ workspace. The C++ stays exactly where it is, compiled, tested, and default-on, until a phase is finished and proven.ORBIT_OBJECT_BACKEND=cpp|rust|both — selects the implementation at runtime. both runs the two side by side and ORBIT_CHECKs that they agree.docs/blog/ as it happens, with every number traceable to a committed snapshot. Numbers that get worse are reported too.//bazel/deps:llvm is gone — zero dependents, both patches deleted, the version pin that existed only because the registry had nothing newer removed. Phase 2 is complete: ELF, PE/COFF and PDB all read by Rust, verified against 16.3 million symbols with zero structural disagreements. What it cost is three rendering differences totalling well under a percent, and the loss of the comparison mode itself. See post 05.The mechanism: one factory, three backends
The obvious way to do this is to build a second copy of each test target linked against the Rust library. It works, but it only proves that both implementations pass the same assertions — not that they produce the same answers. Two implementations can both satisfy EXPECT_EQ(symbols.size(), 4) and still disagree about every address.
So instead: keep one factory, and give it a third mode that runs both and compares.
srcs, data and testdata glob — the only difference is one entry in env.Three small things make this work, and none of them touches a test:
- Split the headers into their own target.
ObjectUtilsInterfacecarriesinclude/ObjectUtils/*.hand nothing else, so the Rust shim can implementElfFilewithout depending on the LLVM implementation. Pure BUILD refactor, no source change. - Rename the existing factory to
CreateElfFileCpp()in an internal header, and add a ~60-lineBackend.cppwhoseCreateElfFile()dispatches. The public signature ininclude/ObjectUtils/ElfFile.hdoes not change, so all 16 dependents keep compiling untouched. - Default to
cpp. An unset variable means today's behaviour, exactly. Nothing in production changes until a phase is finished and the default is flipped deliberately.
RustElfFile holds a std::unique_ptr<ElfFile> built by CreateElfFileCpp() and overrides methods one at a time. On the first commit it might override three of eleven and delegate the rest. That keeps :ObjectUtilsTestsRust passing from day one, makes every commit independently mergeable, and turns "is the port finished" into a countable question: how many methods still delegate. When the count reaches zero the delegate is removed and ElfFile.cpp can be deleted.
Four verification gates
Each is cheap to run, runs in CI, and catches a different class of mistake. A phase is not done until all four are green.
Conformance — the existing suite, three ways
bazel test //src/ObjectUtils:all runs the same 66 test cases three times. This catches anything the original authors thought worth asserting, which for ElfFileTest is a lot: load bias with and without program headers, .eh_frame versus .debug_frame, compressed debug sections, inlined line info, and the exact error strings.
Discipline this imposes: the Rust backend must reproduce Orbit's own error text verbatim, because eight assertions match on it — for example "Unable to get load bias of ELF file: \"%s\". No executable PT_LOAD segment found.". All eight are Orbit's strings, not LLVM's, so this is bounded work rather than an impossible constraint. Never surface an object or gimli error where a test expects an Orbit one.
Differential — a real corpus, not a curated one
A symbol_dump tool that takes a path and writes ModuleSymbols as text proto, plus a sh_test that runs it under cpp and rust over every ELF it can find and diffs the pair. Corpus: the 34 files in src/ObjectUtils/testdata, plus everything Bazel just built in bazel-bin, plus /usr/lib/x86_64-linux-gnu/*.so* when present.
Why this and not just gate 1: the curated testdata is a dozen small binaries chosen to exercise specific code paths. A real libQt5Core.so or a stripped-and-debuglinked libc exercises combinations nobody wrote a test for. This is where object-versus-LLVM disagreements about odd section layouts will actually show up.
Fuzz parity — the property the fuzzer was already for
ElfFileLoadSymbolsFuzzer.cpp already exists and calls CreateElfFileFromBuffer on arbitrary bytes. Point it at both mode: now it is not just "does this crash" but "do the two implementations agree, or fail in the same way, on malformed input". Both implementations must reject the same buffers.
Note: the fuzzer is a CMake-era file with no Bazel target. Part of this phase is giving it one — worth doing regardless of the port.
Performance — measured, not assumed
Criterion benches inside the Rust crate for micro-level work, plus an A/B harness that loads symbols from the same corpus N times under each backend and reports wall time and peak RSS. Because the backend is an environment variable, that is one binary run twice — no separate build.
Pass criterion: Rust is not slower on LoadDebugSymbols for a large module, and does not use more peak RSS. If it is slower, the phase does not merge until it isn't — "correctness first, performance later" is how a port quietly becomes a regression.
Where the Rust lives
A new top-level rust/ workspace, deliberately separate from src/OrbitLiveViewer. Two reasons: the symbol parsers should not share a lockfile with a tokio-and-wgpu dependency tree, and keeping the new work in one directory is what makes the side-by-side comparison legible.
rust/
├── Cargo.toml workspace root; resolved by crate_universe as @orbit_crates
├── Cargo.lock
├── rust-toolchain.toml pinned; independent of OrbitLiveViewer's 1.88
├── BUILD.bazel rust_library / rust_test / rust_static_library per crate
├── README.md what lives here, and the backend switch
│
├── crates/
│ ├── orbit-maps/ Phase 1 — /proc/pid/maps parsing
│ ├── orbit-object/ Phase 2 — ELF, COFF, PDB; object · gimli · addr2line
│ ├── orbit-demangle/ Phase 2 — Itanium + MSVC demangling
│ └── orbit-perf-merge/ Phase 3 — ordered stream merge, thread state, uprobe maps
│
├── ffi/ thin #[no_mangle] extern "C" layers, one per crate
│ ├── orbit-maps-ffi/
│ ├── orbit-object-ffi/
│ └── orbit-perf-merge-ffi/
│ └── include/ the .h each one exports, hand-written like pyspy_ffi.h
│
├── shims/ C++ that implements Orbit's existing interfaces over the FFI
│ ├── ModuleUtilsRust/
│ └── ObjectUtilsRust/ RustElfFile, RustCoffFile, ComparingElfFile
│
└── tools/
├── symbol_dump/ gate 2 — dumps ModuleSymbols as text proto
└── ab_bench/ gate 4 — same binary, two backends, wall time + peak RSS
The C++ shims live under rust/ rather than beside the module they serve. That is slightly unusual, and it is the right trade here: it keeps src/ObjectUtils at one new file (Backend.cpp) and one changed BUILD.bazel, so git diff on the C++ tree stays readable for the whole port.
A third crate_universe repository (@crates for py-spy, @live_crates for the viewer, @orbit_crates for this) is one more lockfile to keep current. The alternative — folding these crates into the existing viewer workspace — couples the symbol parser's dependency resolution to eframe's. I would take the third repository, but it is worth being deliberate about, because merging them later is harder than splitting them now.
Phase 0 — scaffolding
No behaviour change, no Rust logic. This phase exists so that Phase 1 is a single small commit rather than a single large one.
rust/workspace,rust-toolchain.toml,@orbit_crateswired intoMODULE.bazelnext to the two that already exist.- A
orbit_dual_backend_test()Starlark macro inbazel/: given acc_test's attributes and a backend variable name, it emits the three targets so no BUILD file repeats itself three times. - A no-op Rust crate with one FFI function and one
rust_test, linked into a throwawaycc_test, to prove the toolchain end to end before any real code depends on it. - CI: add the new targets to
.github/workflows/build-and-test.yml. They should be gating from the first commit — a non-gating conformance suite is not a gate.
Phase 1 — the 73-line pilot
orbit_module_utils::ParseMaps is the smallest real thing in the tree that is worth porting, and it is close to a perfect first target:
// src/ModuleUtils/include/ModuleUtils/ReadLinuxMaps.h
[[nodiscard]] std::vector<LinuxMemoryMapping> ParseMaps(std::string_view proc_pid_maps_content);
A pure function. Text in, five integers and a string out, per row. No I/O, no allocation policy to match, no error type to reproduce — it cannot fail, it just returns fewer rows. 73 lines of implementation, 89 lines of test, four test cases. If the machinery from Phase 0 works, this lands in a day; if it doesn't, it fails cheaply and visibly.
It is also genuinely on the hot path — ReadModules parses the whole maps file for every process listed, and the process list is polled — so it gives gate 4 something real to measure on the first try.
FFI shape: one call in, one flat buffer out. Rust returns a count plus a pointer to an array of { start, end, perms, offset, inode, path_offset, path_len } and a separate string blob; the shim walks it once building std::vector<LinuxMemoryMapping>, then frees. No per-row allocation across the boundary, no ownership subtleties, and it is the same pattern every later crate will use.
Phase 2 — ObjectUtils, and deleting LLVM
This is the phase with the payoff. I checked the dependency graph specifically: //bazel/deps:llvm has exactly one dependent in the entire tree, and it is //src/ObjectUtils. There is one other LLVM include anywhere — llvm/Demangle/Demangle.h in CaptureClient/CaptureEventProcessor.cpp, which does not even declare the dependency and picks it up transitively.
ObjectUtils's abstract interface, which does not change. That makes "delete LLVM" a bounded, verifiable outcome rather than an aspiration — and it is the single largest dependency in the tree.Sub-stages, and the tests each one lights up
Each row is a mergeable commit. :ObjectUtilsTestsRust and :ObjectUtilsTestsBoth stay green throughout, because everything not yet ported still delegates to C++.
| Stage | What moves to Rust | Crates | Tests it takes over |
|---|---|---|---|
| 2a | ELF metadata: build id, soname, load bias, executable segment offset, image size, object segments, Is64Bit, HasDebugInfo, HasDynsym |
object | LoadBias… (+Static), ObjectSegments (+Static), CalculateLoadBiasNoProgramHeaders, GetBuildId, GetFilePath, CreateFromBuffer, FileDoesNotExist, HasDebugInfo, DoesNotHaveDebugInfo, HasDynsym, GetSonameSmoke, GetNameForFileWithoutSoname |
| 2b | Symbol tables: LoadDebugSymbols, LoadSymbolsFromDynsym, HasDebugSymbols, demangling |
object · cpp_demangle | LoadDebugSymbols, HasDebugSymbols, LoadSymbolsFromDynsym (+Fails), CompressedDebugInfo |
| 2c | Unwind ranges as symbols — .eh_frame and .debug_frame FDE walking |
gimli | LoadEhOrDebugFrameEntriesAsSymbolsFrom{EhFrame,DebugFrame}, LoadDynamicLinkingSymbolsAndUnwindRanges… (both), LoadPatchableFunctionEntry… (both) |
| 2d | DWARF line tables and declaration locations — done | gimli · addr2line | Reworked onto gimli directly, dropping addr2line, which let LLVM's own rules be applied: getFileNameByIndex for paths, and the inlining rule where GetLineInfo reports the outermost frame's call site while GetDeclarationLocationOfFunction reports the innermost DIE. All 1,235 line numbers match; 2 source paths render differently. See post 05. |
| 2e | .gnu_debuglink section and its CRC |
object | HasNoGnuDebugLink, HasGnuDebugLink, CalculateDebuglinkChecksum{Valid,NotFound} |
| 2f | PE/COFF: export table, exception table, PDB debug directory, build id from PDB info | object | all 17 CoffFile cases |
| 2g | PDB reading — done | pdb · msvc-demangler | The CodeView type-name renderer was reimplemented after all — TypeNameComputer plus TypeIndex::simpleTypeName, about two hundred lines. PdbFileTest turns out to specify it, one construct per symbol, and all of them pass. 12,420 PDB symbols compared; one vtordisp thunk name differs. |
| 2h | Done. //bazel/deps:llvm has zero dependents, both LLVM patches are deleted, and the cold build of ObjectUtils fell 34% (158.7s to 104.6s, critical path 68.3s to 48.3s). ObjectUtils lost 2,191 implementation lines. Timing. |
— | full bazel test //..., plus a cold-build timing before and after |
It would be convenient if PDB reading were Windows-only, but it isn't. CreateSymbolsFile tries CreatePdbFile unconditionally on Linux, and SymbolsFileTest asserts on the failure message — because Orbit does Wine/Proton mixed-callstack profiling and has to read PE and PDB for Windows modules running under Proton on a Linux host. So LLVM cannot be deleted until the pdb crate covers what PdbFileLlvm does. That crate is mature for symbol and address lookup, which is all Orbit asks of it, but this is the stage most likely to need a fallback — and the fallback is ugly: keeping LLVM for one library.
Phase 3 — the pure-logic half of LinuxTracing
Two classes were ported and validated — PerfEventQueue (slab-and-handles FFI) and ThreadStateManager (pure integers) — and both measured slower: ~20% on a 63 ns baseline and ~2.4× on a 13 ns one, with identical checksums throughout. The toll of an opaque cross-language call is fixed per event, so the smaller the function, the larger the relative loss, and the five remaining classes are all smaller. Defaults stay C++; the Rust stays in tree behind ORBIT_PERF_MERGE_BACKEND and ORBIT_THREAD_STATES_BACKEND; the rest of the phase is deliberately not ported. What could reopen it: cross-language LTO, or moving the boundary to the wire format. See post 06 and the verdict.
Reopened by decision. The project owner overruled the gate: everything portable is ported and defaults to Rust, the toll is accepted and priced (~2% of a core at a million sched events per second), and the C++ twins stay behind their switches until cross-language LTO or the wire-format boundary removes it. Only the two libunwindstack-welded classes stay C++, on the dependency boundary the analysis drew from the start. See post 07 and the accepted costs.
Seven classes in src/LinuxTracing do no I/O, open no file descriptors, and touch no kernel interface. They are algorithms with unusually good test coverage — 1,355 lines of implementation against 3,053 lines of test, a ratio of better than two to one.
| Class | Impl | Test | What it does |
|---|---|---|---|
PerfEventQueue | 220 | 422 | Heap-of-queues merge of per-CPU streams into one timestamp order |
ThreadStateManager | 334 | 593 | Per-thread state machine over sched tracepoints |
LeafFunctionCallManager | 238 | 596 | Frame-pointer fixup for leaf frames |
UprobesReturnAddressManager | 227 | 799 | Stitches return addresses displaced by uprobes |
ContextSwitchManager | 122 | 256 | Pairs switch-in and switch-out into slices |
UprobeAddressMap | 122 | 182 | Address range bookkeeping |
UprobesFunctionCallManager | 92 | 205 | Entry/exit pairing into function calls |
The FFI here is different in kind from Phase 2, and worth thinking about before starting rather than during. These classes sit on the hot path and traffic in PerfEvent, a large C++ variant. Copying it across a boundary per event would swamp any gain.
The shape that works: the Rust side never sees a PerfEvent. It owns only the ordering keys — (timestamp, stream_id, u64 handle) — and the C++ side keeps the events in a slab, passing indices. PerfEventQueue becomes a Rust data structure over three integers per element, which is what it actually is; the payload never moves. Same for the state managers, which key on (tid, timestamp, state).
This phase is also where gate 4 earns its keep, because it is the one place where a Rust port could plausibly be slower — an FFI call per event is not free, and the batching has to be designed rather than discovered.
The endgame — phases 4 to 8, to full Rust
The goal is now explicit: no C++ in the service. The batching question answered itself along the way — buffering events across the FFI boundary to amortize the toll is a miniature of the wire-format boundary, so rather than build the miniature and throw it away, the plan goes straight to the real one. The per-event toll accepted in post 07 is deleted by Phase 4 as a side effect, not patched.
| Phase | What moves | What it deletes |
|---|---|---|
| 4 — the collector | PerfEventRecords, PerfEventReaders, PerfEventRingBuffer, PerfEventOpen, the TracerImpl event loop and its visitors: Rust owns the ring buffers and calls the five Phase 3 crates natively | Every per-event FFI crossing, and with it the accepted toll |
| 5 — the unwinder | framehop + the gimli/object crates already in tree replace libunwindstack; LeafFunctionCallManager, UprobesReturnAddressManager, UprobesUnwindingVisitor follow | libunwindstack — the largest third-party C++ dependency since LLVM, and the weld that kept two classes C++ |
| 6 — instrumentation | UserSpaceInstrumentation: ptrace, tracee allocation, trampoline injection via the nix crate | The last kernel-interface C++ |
| 7 — the service shell | LinuxCaptureService, ProducerEventProcessor, the small services, transport moves from gRPC/protobuf to the pod-style wire format, main() | gRPC and protobuf from the service side |
| 8 — deletion | Residual C++ test coverage is translated into the Rust suites, which until this point were forbidden to replace the C++ oracles | The C++ twins, the facades, the entire rust/ffi layer, the retired tests |
Ordering rationale: the collector goes first because it is the only phase that pays a debt already on the books (the toll), and because its wire format is a prerequisite for Phase 7. The unwinder goes second because it unblocks the two welded classes and because a shipping precedent exists — samply is an all-Rust Linux profiler built on framehop, so Phase 5 is engineering, not research. Phases 6 and 7 have no ordering constraint between them. Phase 8 is last by construction: the C++ tests are the parity oracle, and translating them earlier would destroy the guarantee they exist to provide.
4a and 4b are done and committed; 4c has its first two milestones done (the ring buffer with its cross-watcher differential, and the event loop composing the ported crates FFI-free). The sched-tracepoint pipeline and the 4d whole-capture differential are root-gated on the development machine and wait for a sudo run. See post 08.
Phase 5 is done through 5c. orbit-unwind (framehop + object) holds frames identical with libunwindstack on 12,000 live samples, and both formerly-welded classes — UprobesReturnAddressManager and LeafFunctionCallManager — now run their logic on Rust by default behind the shared switch, engines reached through callbacks. libunwindstack stays linked until Phase 8, by design. See post 09.
Phase 6 is ported through 6d. orbit-ptrace holds the substrate (attach, tracee memory, register backup, syscall injection, MemoryInTracee), and orbit-trampoline holds placement, the instruction relocator (byte-for-byte identical with the C++ across 888,154 real instructions, on iced-x86), the fixed code sequences, and the whole-trampoline builder (93,696 trampolines byte-identical over real function starts). Every algorithmic and code-gen part of instrumentation is ported and byte-verified; only InstrumentProcess (live installation, driven by the service request) remains, and it is verified end-to-end. See post 10.
Phase 7 has started. The pod capture wire format (orbit-wire) is built and measured — 4.79× faster to parse than protobuf at 1.69× the bytes, zero dependencies, round-trip tested over 200,000 events — and the first all-Rust vertical slice runs sched-switch records through the ported ContextSwitchManager into pod SchedulingSlice events with no FFI on the path. What remains is the rest of the event types, the service shell (LinuxCaptureService, ProducerEventProcessor, main), and InstrumentProcess. See post 11.
Phase 4 milestones, each with its own differential
- 4a — record layouts. The packed ring-buffer record structs in a
orbit-perf-recordscrate, with a cross-language layout parity test: a C++ test comparessizeof/offsetofof every struct inPerfEventRecords.hagainst the sizes the Rust crate exports. Layout is the spec for a wire parser; this is the cheapest differential in the whole plan. - 4b — the reader. Ring-buffer byte streams parsed by both sides: identical raw bytes in, identical parsed records out.
- 4c — the loop.
perf_event_open, the mmap ring buffers, and theTracerImplevent loop in Rust, calling the Phase 3 crates natively. - 4d — whole-capture differential. C++ tracer and Rust tracer run against the same workload; event streams compared modulo the noise a real kernel injects. This is gate 2 scaled up from symbols to captures.
How each goal gets measured
| Goal | Instrument | Pass criterion |
|---|---|---|
| Correctness | Gates 1–3: three test modes, differential corpus, fuzz parity | 66/66 in all three modes; zero diffs across the corpus; both backends reject the same malformed buffers |
| Performance | rust/tools/ab_bench — same binary, BACKEND=cpp then rust; criterion for the crate internals |
No regression in wall time or peak RSS on LoadDebugSymbols for a large module. Record the numbers in the commit message, not just "looks fine". |
| Ease of build | Cold bazel build //src/OrbitService with an empty disk cache, before and after 2h |
LLVM's compile leaves the critical path; bazel/patches/ loses both LLVM patches; no version pin that exists only because the registry has nothing newer |
| Shrinking dependencies | bazel query 'deps(//src/OrbitService)' | wc -l, recorded at each phase boundary |
//bazel/deps:llvm reaches zero dependents and is removed from MODULE.bazel. Report the target-count delta. |
Record all four at every phase boundary, in the commit. A port that cannot show its numbers is indistinguishable from a rewrite that felt good.
The log, and what counts as proof
Every phase is written up in docs/blog/ as it happens. The posts are not a summary written afterwards — they are the record, and the discipline they impose on the work is the point: a phase is not finished until its numbers are reproducible by someone else.
Four rules, chosen because each one is a way the write-up could otherwise become worthless:
- No claim without a committed number. Every figure a post cites comes from a script in
scripts/whose output is checked in underdocs/blog/metrics/. A reader can re-run it and disagree. - The C++ test files are never edited. They are the specification. A port that adjusts its own exam has not passed anything — and this is the single easiest corner to cut, because a failing assertion always looks like it might be the test's fault.
- Numbers that get worse get reported. A strangler port makes the tree bigger before it makes it smaller. Hiding that would make the good numbers worth less, and the first post already reports a dependency count going up by 56 and a module gaining 137 lines.
- Every hurdle is written down, including the ones that are self-inflicted. Post 01 records eight, of which two were bugs in the new code and one was a bug in a new test.
The two instruments
| Command | Output | What it records |
|---|---|---|
| scripts/port_metrics.sh --bazel | docs/blog/metrics/<phase>.json | Implementation and test lines per module; Rust lines in tree; deps() target counts and distinct external repositories for ObjectUtils, ModuleUtils and OrbitService; gtest case counts; bazel_dep and patch counts; LLVM's dependent count; and the tally of hand-rolled std equivalents — ErrorMessageOr uses, absl::Mutex versus GUARDED_BY, and the line count of the OrbitBase headers that std makes redundant |
| scripts/bench_parse_maps.sh | docs/blog/metrics/<phase>-bench-*.txt | A/B timing: one binary, two backends, alternating rounds so a warming CPU cannot favour whichever runs first, median reported. One of these per ported module |
The baseline snapshot was taken before any Rust existed, on the commit the branch was cut from. That is what makes every later delta meaningful, and it is the one measurement that cannot be retaken.
Fewer dependencies: deps(//src/ObjectUtils) was 5,843 targets across 17 external repositories at baseline; deps(//src/Service:OrbitService) was 22,220 across 334. Phase 2h should move all four numbers down, and //bazel/deps:llvm's dependent count from 1 to 0.
Fewer lines: tracked per module, and expected to rise during a phase and fall at its end when the C++ is deleted. Reporting the rise is what makes the fall believable.
No re-implemented error types: ErrorMessageOr appears 1,352 times and exists because C++ had no Result; the OrbitBase headers that std, tokio or rayon would provide come to 2,963 lines. Both counts are in every snapshot, so the reduction is a measurement rather than an assertion.
Faster, or at least not slower: one A/B benchmark per ported module, with the mechanism explained in the post. A number without a mechanism is a coincidence.
Post index
| # | Post | Phase | Headline result |
|---|---|---|---|
| 01 | The First 73 Lines | 1 — ParseMaps | 1.75× faster, zero dependencies, 4 C++ tests green on both backends, and the sabotage experiment proving the comparing mode really compares |
| 02 | ObjectUtils metadata — pending | 2a | — |
What can go wrong
object and gimli are not LLVM. They are excellent and they are what the Firefox profiler and Sentry run on, but they are not bug-for-bug identical. The likely friction points are already named by the test suite: CompressedDebugInfo (the Bazel port had to patch LLVM just to turn zlib on for SHF_COMPRESSED), LineInfoInlining (inlined frames in DWARF line tables), and DWARF 5 forms. Gate 2 over a real corpus is how these surface early rather than in a user's capture.
Error strings are load-bearing. Eight assertions match on exact Orbit error text. That is a small, bounded requirement, but it is easy to forget until CI fails — and easier still to "fix" by editing the test, which would quietly destroy the value of gate 1. The rule: the test file is never edited during a port. If a test seems wrong, that is a separate commit, made against the C++ backend, before the port touches it.
both mode has a cost. Running two implementations on every call roughly doubles the work in that suite. That is fine for a test target and would not be fine anywhere near production, so the mode must be reachable only via the environment variable and never become a default.
The PDB stage may not land. Covered above. If pdb cannot reproduce PdbFileLlvm, LLVM survives for one library and the build-time win mostly evaporates while the correctness and dependency wins remain. Worth deciding in advance whether that outcome is acceptable, because it changes what "finished" means for Phase 2.
The C++ never gets deleted. The failure mode of a strangler is that both implementations live forever and every change has to be made twice. The countermeasure is the delegation counter: it is a number, it should only ever go down, and when it reaches zero the deletion commit is already written.
The first pull request
Scoped so it either works or fails visibly, in about a day, with no Rust logic in it at all beyond a parser that four existing tests already specify.
PR 1 — scaffolding plus the pilot
- Create
rust/— workspace,rust-toolchain.toml,@orbit_cratesinMODULE.bazel, a README explaining the backend switch. - Add
bazel/dual_backend.bzl— theorbit_dual_backend_test()macro that emits the threecc_testtargets from one set of attributes. - Write
rust/crates/orbit-maps—parse_maps(&str) -> Vec<Mapping>, plus Rust unit tests ported fromReadLinuxMapsTest.cppso the crate stands alone. - Write
rust/ffi/orbit-maps-ffi— oneextern "C"entry point, flat output buffer plus string blob, one free function, a hand-written header in thepyspy_ffi.hidiom. - Write
rust/shims/ModuleUtilsRust—ParseMapsRust()returningstd::vector<LinuxMemoryMapping>. - Add
src/ModuleUtils/Backend.cpp—ParseMaps()dispatches onORBIT_MAPS_BACKEND;bothcompares element-wise andORBIT_CHECKs. Defaultcpp. - Emit the three test targets for
ReadLinuxMapsTest.cppand add all three to CI as gating. - Add
rust/tools/ab_benchwith one case: parse a large real/proc/self/mapsN times under each backend, print wall time and peak RSS. Paste the numbers in the PR description.
If that PR is green in all three modes and the benchmark does not regress, everything after it is the same moves at larger scale. If it isn't, the problem is in the machinery rather than in 73 lines of maps parsing, and it is worth finding out there.
Say the word and I'll start on it. My suggestion is to do PR 1 exactly as scoped — resist adding ObjectUtils to it, because the point of PR 1 is to be small enough that when something breaks it is obvious which of the eight items broke it.