Orbit C++ → Rust · Post 08

Moving the Boundary

Post 07 accepted a per-event FFI toll and named two exits. This post takes the second one: instead of batching calls across the boundary, the boundary itself moves to the wire — Rust now owns the record layouts, the record parsing, perf_event_open, the ring buffers, and the event loop.

19Structs, every field compared
7,230Kernel records, 0 mismatches
600/600Ring records, both watchers
3Sabotage checks, all bit
0FFI calls on the new path
1External crate (libc)

The question that reopened this phase was "could we at least buffer the FFI calls to amortize the toll?" The answer turned out to be an argument against itself: batching POD events across a boundary and getting POD results back is the wire-format boundary, in miniature. Designing the miniature, shipping it, and then deleting it when the real one lands is work spent twice. So Phase 4 goes straight at the real one, one verified layer at a time.

Three layers, three different proofs

Each layer of the collector gets the cheapest differential that actually pins its behavior — and each one comes with a sabotage check, because a differential that has never failed has never been tested.

LayerCrateProofResult
Record layouts (4a)orbit-perf-recordsC++ sizeof/offsetof of every field of all 19 structs vs the crate's exports19/19, both arches
Record parsing (4b)orbit-perf-records::readerLive kernel records fed to both parsers, renderings compared7,230 records, 0 diffs
Ring + syscall (4c)orbit-perf-ringC++-opened and Rust-opened buffers watch the same child; deterministic records must match in order600 vs 600, 0 diffs
Event loop (4c)orbit-collectorProcessor semantics mirrored from the C++ tests; live two-buffer ordering teststrict global order

4a — layout is the spec

For a wire parser, the struct layout is the contract, so it gets the strictest and cheapest check in the whole plan. One Rust macro generates each packed struct, its byte parser, and its field-offset table from a single field list — struct and parser cannot drift from each other by construction. Then a C++ test walks every struct in PerfEventRecords.h and compares sizeof and the offsetof of every field against what the crate exports, with a field-count check so a field added on one side fails loudly instead of going uncompared. Sabotage: a bogus u64 inserted into the Rust ForkExit — the C++ test fails on offsets and count.

4b — the kernel writes the test vectors

Phase 2 verified symbol parsing against a corpus of 4,719 real binaries. The reader's corpus writes itself: a tool opens real perf buffers using Orbit's own open functions — so the attributes cannot diverge from the consumers — runs a child workload, and for every record the kernel produces, parses it twice: C++ from the ring buffer, Rust from the same raw bytes. The renderings must match field for field, stacks compared by hash. 7,230 records across 12 runs: identical.

The port preserves the C++ walk quirk for quirk: the stack copy takes only dyn_size of the stack_size bytes; dyn_size itself is read only when stack data is copied while the cursor still advances past it; "//anon" becomes the empty filename and anonymous page offsets are normalized. One deliberate divergence, documented: the Rust parser returns None on a truncated record where the C++ trusts the kernel and reads on.

And one hurdle worth its ink: the first differential run compared zero records and printed a green verdict. Orbit opens every perf event disabled — TracerImpl enables them explicitly once the buffers exist, and the tool now does too. A differential that can pass on an empty corpus is a footgun; the record counts are printed in the verdict for exactly that reason.

4c — Rust makes the syscall

orbit-perf-ring builds perf_event_attr itself (the VER8 layout, flag bits laid by hand), makes the syscall, maps the ring, and speaks the acquire/release tail/head protocol. Unsafe code is confined to a hundred-line sys module; the wrap arithmetic — the part with actual edge cases — is a pure function unit-tested against synthetic rings.

The differential runs one child workload under two watchers: a buffer opened and read by the C++ path, and one opened and read entirely by Rust. The kernel delivers the same mmap/fork/exit records to both, so after normalizing the per-buffer fields (timestamps, stream ids) the sequences must match record for record — 600 records over 12 runs, zero mismatches. Sabotage: dropping the mmap_data attribute bit made the anonymous mappings vanish from the Rust stream, divergent at record zero.

Two entries for the hurdle log. First: perf_event_open's pid argument is a thread id. The crate's self-test watched the process id — which is the main thread — while the test harness ran it on a worker thread: green example binary, red test, zero records, no error anywhere. The C++ never hits this because it watches per-CPU, not per-thread. Second: the service-side Rust now has its first external crate, libc — the syscall layer has to come from somewhere — with exactly zero transitive dependencies.

The loop, and the moment the crates compose

orbit-collector ports TracerImpl's orchestration: round-robin draining in batches of five so no buffer starves while another overflows, the idle sleep, and PerfEventProcessor's discipline — buffer everything, process only records older than the 333 ms delay so late arrivals from other buffers can still be ordered in, discard and count stragglers. The constants are the C++ constants; the semantics are pinned by tests mirroring the C++ suite plus a live test that demands strict global timestamp order across two kernel buffers.

This is the milestone where the ported crates stop being islands. The collector calls orbit-perf-ring, orbit-perf-records and orbit-perf-merge natively. The slab-and-handles design that Phase 3 built to keep PerfEvent payloads from crossing the FFI boundary turned out to be the right design with no boundary at all — the merge queue still orders three integers per event while payloads stay put. The ~5–9 ns toll post 07 priced does not exist on this path. Not shrunk: absent.

What stands between here and 4d