Orbit's capture backend is 34,377 lines of C++. The plan for moving the parts of it that benefit from Rust is written up separately; this is the first step actually taken. It is deliberately tiny — one pure function, 38 lines of implementation — because the point of step one is not to port anything valuable. It is to find out whether three C++ test targets can run against a Rust implementation, and whether a deliberately broken Rust implementation makes them fail.
What got ported
orbit_module_utils::ParseMaps reads the text of /proc/[pid]/maps and returns one struct per mapping. Orbit calls it for every process in the process list, and the process list is polled, so it is genuinely on a hot path while also being trivially testable: text in, five integers and a path out, no I/O, no failure mode.
// src/ModuleUtils/include/ModuleUtils/ReadLinuxMaps.h — unchanged
[[nodiscard]] std::vector<LinuxMemoryMapping> ParseMaps(std::string_view proc_pid_maps_content);
That signature does not change, and neither does any of its callers. What changes is that the definition now lives in a new Backend.cpp that dispatches on ORBIT_MAPS_BACKEND to one of three paths: the original C++, the new Rust, or both, comparing the results and aborting if they differ. The original C++ is renamed ParseMapsCpp and otherwise untouched.
The gate, and proving it is real
Three cc_test targets are generated from one set of attributes by a small Starlark macro. They share srcs, deps and data by construction and differ only in one entry of env, so a suite cannot drift between backends.
$ bazel test //src/ModuleUtils:all //rust:all
//src/ModuleUtils:ReadLinuxMapsTests PASSED
//src/ModuleUtils:ReadLinuxMapsTestsRust PASSED
//src/ModuleUtils:ReadLinuxMapsTestsBoth PASSED
//src/ModuleUtils:ModuleUtilsTests PASSED
//rust:orbit_maps_test PASSED
//rust:orbit_maps_ffi_test PASSED
Executed 6 out of 6 tests: 6 tests pass.
A green test suite proves nothing on its own. If the environment variable were not reaching the code, or the comparison never ran, all three would pass exactly like this. So before believing any of it, I broke the Rust on purpose — a one-character change, inode becoming inode + 1, the kind of difference no type system catches:
$ sed -i 's/^ inode,$/ inode: inode + 1,/' rust/crates/orbit-maps/src/lib.rs
$ bazel test //src/ModuleUtils:ReadLinuxMapsTests{,Rust,Both}
//src/ModuleUtils:ReadLinuxMapsTests PASSED in 0.3s
//src/ModuleUtils:ReadLinuxMapsTestsRust FAILED in 0.3s
//src/ModuleUtils:ReadLinuxMapsTestsBoth FAILED in 0.9s
Fatal: ParseMaps backends disagree at entry 0:
cpp: 0x400000-0x452000 perms=0x5 offset=0 inode=173521 path="/usr/bin/dbus-daemon"
rust: 0x400000-0x452000 perms=0x5 offset=0 inode=173522 path="/usr/bin/dbus-daemon"
That is the result I wanted: the C++ target is unaffected, proving the original path is genuinely untouched; the Rust target fails on the assertion; and the comparing target aborts with both values printed. The gate works. Everything after this rests on that one experiment, so it is worth re-running whenever the machinery changes.
Why it is 1.75× faster
I did not expect a speedup from a string parser and was ready to report a regression. The measured result, median of five alternating rounds so a warming CPU cannot favour whichever runs first:
$ scripts/bench_parse_maps.sh 30000 5
iterations 30000, 5 rounds
cpp 8.222 us/parse (median)
rust 4.700 us/parse (median)
speedup 1.75x
The reason is not that Rust is fast. It is that the two versions have completely different allocation profiles, and that is visible in the source rather than inferred from the number.
absl::StrSplit can produce string_views too — the C++ could be rewritten to borrow. The point is not that it is impossible in C++; it is that the safe-by-default version in Rust is also the fast one, so the fast version is what gets written without anyone deciding to optimise.The evidence, including what got worse
Two of these numbers move in the wrong direction. That is expected at this stage and worth showing rather than hiding: the plan deliberately keeps both implementations in the tree until a whole phase is proven, so mid-port the tree is strictly bigger.
| Measure | Before | After | Reading |
|---|---|---|---|
| ParseMaps speed | 8.222 µs | 4.700 µs | 1.75×, median of 5 alternating rounds |
| External dependencies of the new code | 4 Abseil targets | 0 | ascii, numbers, str_format, str_split → orbit-maps has an empty [dependencies] |
| Error handling | n/a here, ErrorMessageOr elsewhere |
Option |
The two new parse helpers return Option<u64>. Nothing was written to express "might not have a value" |
| Test lines edited | — | 0 | ReadLinuxMapsTest.cpp is byte-identical. This is the rule that makes the gate mean anything |
| Tests covering ParseMaps | 4 | 4 C++ × 2 backends + 11 Rust | The Rust tests mirror the C++ assertions one for one, then add cases the C++ never had |
deps(//src/ModuleUtils) |
5,850 | 5,906 | Worse by 56. The Rust toolchain is now in the graph. This does not pay off until LLVM leaves in Phase 2 |
ModuleUtils implementation lines |
557 | 694 | Worse by 137. Both implementations plus a dispatcher, on purpose. The C++ is deleted at the end of a phase, not the start |
The honest summary of the dependency line: a port that keeps the old code cannot show a dependency win until it stops keeping the old code. What it can show now is that the new code has no dependencies at all, which is the thing that will eventually make the graph smaller.
Eight hurdles
cargo existed but was not on PATH
which cargo returned nothing, which looked like "no Rust toolchain" and nearly sent me down the path of doing every iteration through Bazel. ~/.cargo/bin was there the whole time and simply not exported. Thirty seconds lost, but worth recording because the failure looks identical to a much bigger problem.
/proc paths are not guaranteed UTF-8
The obvious signature is parse_maps(&str). It is wrong. The C++ takes a std::string_view — bytes — and a path in /proc/[pid]/maps is whatever the kernel recorded, which need not be valid UTF-8. Taking &str would have added a failure mode the original does not have, on input Orbit does not control.
So the crate works on &[u8] throughout and pathname is a Vec<u8>. There is a test for it. This is the kind of thing that would have shipped silently and then failed on one user's machine.
Vec::with_capacity(usize::MAX + 1)
My split helper reserved max_splits + 1, and I called it with usize::MAX to mean "unbounded" when splitting the address range. Every test panicked instantly with attempt to add with overflow.
The fix was to stop abusing the helper and use a plain split() iterator for the address field, plus a saturating, capped reservation so a caller-supplied bound can never overflow. Worth noting what happened here: the bug was in the new code, and Rust's debug overflow check found it in the first test run, in 0.00 seconds. In C++ this is reserve(SIZE_MAX), which is a bad_alloc at best and a silent wrap at worst.
The overflow test did not test overflow
I wrote parse_maps(b"00000000000000017-1 …") expecting a rejected line, on the theory that 17 hex digits cannot fit in a u64. Seventeen zeros are worth zero. The test failed, correctly, and the code was right all along.
A small embarrassment, but the useful lesson is that it was the test that was wrong and the test suite said so. Had I written the assertion loosely enough to pass, I would have shipped an untested overflow path.
std::stoull throws; the Rust skips the line
The C++ parses addresses with std::stoull(s, nullptr, 16), which throws std::invalid_argument on a string with no leading hex digits, and silently ignores trailing garbage. No test covers either case, because a real /proc/maps never contains malformed hex.
The Rust returns None and skips the line, consistent with how every other malformed field in the same function is already handled. This is a deliberate, documented behaviour change, and it is the one place in this commit where the two backends are not equivalent on all possible inputs — they are equivalent on all inputs the kernel produces. A parser reading kernel-supplied text should not be able to terminate the process, and the C++ currently can.
absl::MaxSplits does not collapse repeated separators
absl::StrSplit(line, absl::MaxSplits(' ', 5)) yields at most six fields, and the sixth keeps every remaining space — which is exactly why the C++ then calls StripLeadingAsciiWhitespace on it. Getting this wrong in either direction breaks paths that contain spaces, which the test suite specifically checks with /path with spaces.
Rust's splitn is close but not identical in the edge cases, so the crate has its own split_max with a test pinning the behaviour, including b"a b" producing an empty middle field. Reproducing a library's exact semantics is most of the work in a port like this, and it is invisible in the diff.
The obvious dependency edge is a cycle
The Rust shim implements ParseMapsRust and therefore needs LinuxMemoryMapping from ModuleUtils's headers. ModuleUtils needs the shim to dispatch to it. Bazel, correctly, refuses.
The fix is a headers-only ModuleUtilsHeaders target that both depend on. It is a two-minute change here and worth knowing about now, because the same split is needed for ObjectUtils in the next phase, where the header set is much larger.
ORBIT_CHECK_MSG does not exist
I wrote the comparison using a check-with-message macro that every codebase seems to have. Orbit has ORBIT_CHECK(assertion) and ORBIT_FATAL(format, ...) and nothing that combines them, so the comparison is an explicit if plus ORBIT_FATAL. Trivial, but a reminder that a port touches the host codebase's conventions as much as the language.
What this does and does not prove
It proves the machinery: a Rust static library links into Orbit's Bazel build, three test targets run one suite against two implementations, the comparing mode genuinely compares, and a broken Rust implementation fails loudly and specifically. It also gives the first real data point on speed, with a mechanism behind it rather than a vibe.
It does not prove anything about the parts that matter. ParseMaps is 38 lines with no error type, no lifetime that outlives a call, and no dependency on anything. The next step is ObjectUtils — ELF, DWARF, COFF and PDB parsing, 66 existing test cases, and the only thing in the tree that depends on LLVM. That is where the port either pays for itself or does not.
Everything here is reproducible: scripts/port_metrics.sh --bazel regenerates the JSON, scripts/bench_parse_maps.sh regenerates the timings, and the sabotage experiment is one sed away.