Short answer: yes, but not as "port OrbitService". That framing produces a two-year rewrite of code that already works. The useful framing is that OrbitService is eight loosely-coupled things wearing one binary's name, Rust's advantage over each of them ranges from large to actively negative, and two of them should stay C++ permanently. The order matters more than the decision.
ObjectUtils, CaptureFile and OrbitBase. The client is what makes a partial port expensive.orbit-live-protocol's POD framing to cover the whole capture stream, sorted into three lanes by frequency. Everything else gets easier once the boundary is a protocol rather than an ABI.The port already started
The interesting fact about this question is that it is not hypothetical. OrbitService already links two Rust static libraries and ships them.
third_party/py-spy-ffi is a rust_static_library in LinuxTracing's dependency list. When Orbit samples a Python call stack, that stack is walked by Rust reading another process's memory through ptrace. It has been there long enough to be unremarkable.
src/OrbitLiveViewer is newer and more consequential. It is a six-crate Cargo workspace — orbit-live-event, -ring, -protocol, -render, -server, -ffi — compiled by Bazel into liborbit_live_ffi.a and linked into OrbitService. It runs the HTTP and WebSocket server, owns the event ring buffer, and defines the event vocabulary the browser viewer consumes.
So the questions people usually spend six months answering are already answered here:
rules_rust0.73 is inMODULE.bazel, with two independentcrate_universerepositories resolved from checked-inCargo.lockfiles.- Bazel downloads the Rust toolchain itself. A contributor still needs nothing installed — that was the whole point of the Bazel port.
- Rust and C++ objects link into one binary, on Linux, in CI, today.
- There are already 68,431 lines of Rust in the tree.
The remaining question is not can we. It is which parts, in what order, and what does the boundary cost while we are mid-way.
What the service actually is
"OrbitService" is a convenient name for a binary that does eight barely-related jobs. They have wildly different portability profiles, and lumping them together is what makes the question look intimidating.
The case, both ways
For the port
- The event vocabulary is already Rust, and C++ is translating into it
LiveViewerBridgeis a C++ gRPC client that receives protobufClientCaptureEvents and re-emits them as calls intoorbit_live_ingest_*. That translation exists only because the producer is C++ and the consumer is Rust. Porting the producer deletes it rather than moving it. - ~3,000 lines of OrbitBase stop existing
Result.h,Future.h,Promise.h,Executor.h,ThreadPool.h,TaskGroup.h,StopSource/StopToken.h,WhenAll/WhenAny.h,AnyInvocable.h,Typedef.h,UniqueResource.h,NotFoundOr/CanceledOr.h,Overloaded.h,Chunk/Sort/Align.h. Every one is a hand-built version of something instd,tokioorrayon.ErrorMessageOralone appears 1,342 times and drags in Outcome as a dependency. - The parsers face hostile input, and the team already knows it
ObjectUtilsparses ELF, DWARF, COFF and PDB out of whatever binary the user points at.LinuxTracingdecodes variable-length kernel records out of an mmap'd ring buffer with hand-computed offsets. There is already anElfFileLoadSymbolsFuzzerand libprotobuf-mutator inthird_party— the tree treats this as an attack surface. This is the code where bounds-checked slices pay for themselves. - The heaviest build dependencies go away
Six LLVM libraries, two of which needed patches during the Bazel port, plus libunwindstack and its AOSP satellites (libbase, liblog, libprocinfo, LZMA), plus capstone. The Rust replacements are pure-Rust crates that
crate_universeresolves without a patch directory. - One language across the product The viewer, the wire protocol, the ring buffer and the renderer are Rust. Anyone who can work on the timeline could then work on the collector. Right now those are two disjoint skill sets separated by a C ABI.
- Cross-targets get cheaper
docs/building_arm64.mdexists because building this C++ tree for aarch64 is a project. Rust's cross story is--targetplus a linker.
Against the port
- Two components are rewrites, not ports
The DWARF unwinder is not portable code — it is fifteen years of accumulated Android edge cases. Replacing libunwindstack with
framehopchanges capture output, not just implementation. Same for capstone-driven instruction relocation. These are risky projects that happen to involve Rust; they are not Rust projects. - The Qt client still owns the shared layer
OrbitBasehas 58 dependents,GrpcProtos35,ObjectUtils16 — and the client is most of them. PortObjectUtilsnow and you either keep two symbol parsers or FFI a Rust one back into C++. Both are worse than today. The service port's value is gated on the viewer port finishing. - A half-ported service has a much worse seam than today's The current boundary is fourteen C functions with POD arguments and events flowing one direction. Split the pipeline and the seam carries protobuf messages, errors, cancellation and object lifetimes both ways — a region with neither language's guarantees, on the hot path.
- Two protobuf type universes
prostgenerates different types from the same.proto. Mid-port, every event crossing the boundary is marshalled or re-serialised. At millions of perf events per capture that is a real copy you did not have. - 63,325 lines of tests are the only specification
294 test files, 20 in
LinuxTracingalone, plus a 4,143-line integration suite with a puppet process. Nothing about the subtle behaviour — leaf-function fixups, uprobe return-address stitching, timestamp ordering across CPUs — is written down anywhere else. Tests do not port automatically, and a rewrite without them loses the net exactly where the ice is thinnest. - Toolchain skew is already a live cost
src/OrbitLiveViewer/Cargo.tomlcarries the comment "This workspace is newer than Orbit's C++ rustc pin", and the wasm viewer had to be excluded from the Bazel workspace entirely to keep the wgpu tree out of the dependency graph. Two toolchains, two lockfile regimes, two upgrade cadences — for years. - The collector works There is no user-visible feature at the end of this. The open bug in this tree is in the viewer, not the service.
Where the safety pitch is weaker than advertised
The standard argument for porting a C++ backend is data races. It is worth checking whether it applies here before leaning on it, because in this codebase it mostly does not.
The capture pipeline is not a thread soup. TracerImpl runs two threads: Tracer::Run, which polls every per-CPU ring buffer in one loop, and Proc.Def.Events, which drains deferred events. There is exactly one absl::Mutex between them, and it is annotated. GrpcClientCaptureEventCollector adds a sender thread behind one fully-annotated mutex. The design is a pipeline of single-threaded stages with explicit handoffs — which is to say, it is already the shape Rust would push you toward.
| Area | absl::Mutex | GUARDED_BY | Reading |
|---|---|---|---|
| Service side, all modules | 55 | 12 | Few locks, mostly at stage boundaries; a fifth carry checked ownership |
| ClientData (UI) | 99 | 27 | Where the shared mutable state actually lives |
| OrbitVulkanLayer | 26 | 0 | In the target process — and not a port candidate |
| CaptureClient | 9 | 0 | Client side |
So the honest version is: Rust would make 43 unannotated service-side mutexes compiler-checked, and that is a modest win, not a dramatic one. Anyone selling this port on fearless concurrency is selling the wrong feature.
The safety argument that does hold is narrower and stronger: this service parses untrusted, externally-shaped binary data in two hot places — kernel ring-buffer records and arbitrary object files. That is exactly the workload where a bounds-checked slice is worth more than a code review, and it is why ObjectUtils ranks first below rather than LinuxTracing.
InstrumentProcessTest.Instrument is tagged known_flaky with the comment "race between the injected thread exiting and gRPC's event-engine thread pool being created." It is worth noting that this race lives at the boundary between Orbit's injected payload and gRPC's runtime — a place a Rust port would not have touched, and arguably would have made harder to reason about.
The seam is the whole cost
Every incremental port is really a bet about the boundary. The reason the live viewer landed cleanly is not that Rust is good; it is that the boundary was chosen where the data flow was already narrow, one-directional and made of plain integers.
What Rust deletes from the build
This is the part of the case that is concrete and easy to verify. Every C++ dependency the service carries has a mature pure-Rust counterpart, and in several cases the counterpart is what the rest of the profiling world already uses.
| Today | Used by | Rust counterpart | Confidence |
|---|---|---|---|
| LLVM Object, DebugInfoDWARF, Symbolize, Demangle | ObjectUtils, Symbols | object · gimli · addr2line · symbolic-demangle | High — this is the Firefox and Sentry stack |
| LLVM DebugInfoPDB, DebugInfoCodeView | PdbFileLlvm | pdb | Good, but less complete than LLVM's reader |
| libunwindstack + libbase, liblog, libprocinfo, LZMA | LinuxTracing | framehop | Different unwinder, different failures. Measure first. |
| capstone | USI trampolines, Disassembler | iced-x86 | Strictly better for relocation — it re-encodes, not just decodes |
| gRPC + protobuf (C++) | every service | tonic · prost | High. Same .proto, second codegen. |
| Abseil containers, synchronisation, str_format | everywhere | std · hashbrown · parking_lot | High |
Outcome (ErrorMessageOr) | 1,342 call sites | Result + thiserror | Built in |
raw perf_event_open, mmap, ptrace | LinuxTracing, USI | nix · libc | Same syscalls, same unsafe, no gain |
| krabsetw (ETW), DIA SDK (COM) | Windows only | ferrisetw · windows-rs | DIA is COM and proprietary. Windows is deprecated anyway. |
The LLVM row is the one that matters most. The Bazel port needed two separate patches to LLVM 17 just to build it, and it is the largest single compile in the tree. Deleting six LLVM libraries in exchange for four pure-Rust crates removes a patch directory, a version pin that only exists because the registry has nothing newer, and a meaningful slice of cold-build time.
The parts that should stay C++
Three components should not be ported, and the reason is the same for all three: they execute inside a process Orbit does not own.
Orbit injects liborbituserspaceinstrumentation.so into the target with dlmopen(LM_ID_NEWLM), which creates a fresh linker namespace. The Bazel port already discovered what that namespace does to a library with non-trivial initialisation: OpenSSL's initialiser corrupted the heap and aborted, reproducibly, in a twelve-line harness. A Rust cdylib brings its own runtime setup — std::rt, thread-local machinery, a panic runtime, a much larger static footprint. That is a considerably larger bet than OpenSSL was, in a place where failure means the profiler corrupts the program it is measuring.
The same argument, weaker but still decisive, covers liborbit.so — the manual-instrumentation library a user links into their application — and OrbitVulkanLayer, which the graphics driver loads into any Vulkan process. Both must be small, must not perturb the host, and must be loadable next to arbitrary third-party code. 503 lines of C++ with no runtime is the correct answer and will stay the correct answer.
The trampoline writer is a separate case with the same conclusion. Trampoline.cpp disassembles a function prologue, relocates the displaced instructions, and writes machine code into another process's address space. Rust offers nothing there: every line is unsafe by construction. You would trade eleven reinterpret_casts for eleven unsafe blocks and gain no checking, while taking on the risk of reproducing 17 test files' worth of x86-64 encoding edge cases. (iced-x86 is genuinely better than capstone at instruction relocation — but that is an argument for a bindings-level change, not a port.)
Ranking the eight
Plotting each component on what Rust actually buys against what it costs to get there sorts the question out faster than any amount of prose.
unsafe in either language. Injected code scores below zero because a Rust runtime in a foreign address space is a new risk, not a removed one.A sequence that ships
Four tiers. Each one is independently useful, each one can be stopped after, and none of them requires the next to be worth doing.
Shorten the seam you already have
This is not a port. LiveViewerBridge currently opens a localhost gRPC channel from C++, reads the CaptureResponse stream, and translates each protobuf event into an orbit_live_ingest_* call. Move that client into orbit-live-server as a tonic client and the entire C++ translation layer disappears — the Rust side reads the stream and populates its own ring directly.
Cost: a few hundred lines. Payoff: it proves tonic against Orbit's own .proto files, on the real capture stream, before anything irreversible is committed. If prost-generated types turn out to be awkward against capture.proto, you find out here, cheaply.
ObjectUtils and Symbols
The highest-value module in the tree for this. It parses adversarial input, it has the strongest Rust ecosystem to land on (object, gimli, addr2line, symbolic-demangle, pdb), it has 8 test files and an existing fuzzer to act as the specification, and finishing it deletes six LLVM libraries and two build patches.
The blocker is the 16 dependents, most of them client-side. Do this behind the existing SymbolsFile interface so the C++ side sees no change, or wait until the Qt client is retired. That dependency is the reason this is Tier 1 rather than Tier 0.
CaptureFile and MemoryTracing
Two small, self-contained modules — 1,456 and 773 lines — with data-shaped interfaces and no threading. Neither is exciting; both are excellent practice at the Bazel/Cargo/test-migration workflow before it matters. CaptureFile in particular is a format the Rust viewer will eventually want to read directly anyway.
The LinuxTracing event pipeline — but not the unwinder
The ring buffer reader, the record decoders, PerfEventQueue's timestamp-ordered merge, and the visitor chain. About 7,000 of the module's 10,017 lines, already structured as small independently-testable pieces, with 20 test files to port as the spec.
Keep libunwindstack behind a C ABI throughout. It is already a third-party C++ library reached through a narrow interface — LibunwindstackUnwinder — which makes it the natural thing to leave on the far side of the boundary. Replacing it with framehop is a separate experiment with its own success criterion: run both unwinders over the same recorded captures and diff the resulting stacks. Ship the swap only if that diff is empty or explicably better. Do not bundle it into the port.
Everything that runs in someone else's process
liborbit.so, liborbituserspaceinstrumentation.so, OrbitVulkanLayer, and the ptrace/trampoline core of UserSpaceInstrumentation. Plus WindowsTracing and the DIA reader while Windows remains the deprecated platform.
The one change worth making here is unrelated to the port: swapping capstone for iced-x86 in the trampoline relocator, because iced-x86 re-encodes instructions rather than only decoding them, which is what that code actually needs. That is a dependency decision, and it would be worth making even if none of the rest of this happened.
The wire format is the real boundary
Everything above puts the seam through the process: some modules C++, some Rust, an ABI between them. There is a better place to put it, and the tree is already most of the way there. Put the seam in the wire format and the language question largely dissolves — the C++ service becomes one producer among several rather than the system's centre, and a Rust collector replaces it piecemeal by speaking the same protocol. No FFI, no shared types, no seam.
orbit-live-protocol already does this for the live path, and it does it without protobuf. LiveEvent is #[repr(C)], 32 bytes, little-endian, fixed layout. Frames are magic, version, type, length. The browser reads them with a DataView and no library — no grpc-web proxy, no protobuf-js, no codegen step.
So the question is whether that generalises from six live event kinds to the thirty-nine variants of ClientCaptureEvent. It does — but not by making all thirty-nine POD. The format should follow the frequency.
capture.proto — that is what InternedCallstack and InternedString are.The mechanism that makes the hot lane possible is already in both schemas: hoist the variable-length part out of the record and reference it by id. CallstackSample carries a callstack_id and the frames live in InternedCallstack; LiveEvent carries a name_id and the text lives in an InternedString frame. Once that is done, protobuf is varint-wrapping records that are already fixed-shape and length-prefixing tables that are already append-only. It is pure overhead on both.
What protobuf is actually buying
Two of these are real and need answers. Two do not survive contact with the tree.
| Property | Evidence in the tree | Verdict |
|---|---|---|
| Schema evolution | ClientCaptureEvent has reserved 9, 20, 23, 28, 29, 30 — six field numbers already burned — and a comment reserving 1–15 for high-frequency events because they encode in one byte |
Real, but replaceable. A #[repr(C)] struct has no field numbers: adding a field is a version bump, not a migration. The Hello frame already asserts VERSION and event_size. Negotiate, don't evolve — which means viewer and service ship together. Fine for a first-party pair; not fine for an SDK. |
Existing .orbit files |
The README advertises "Full Serialization of Captured Data"; CaptureFile reads protobuf-framed captures |
Real. Keep a protobuf importer for old files — bounded, one-directional, never extended. Do not keep a parallel writer. |
| Language-neutral producers | ProducerSideService is a documented extension point over a Unix socket |
Weak. Every producer in the tree is first-party: src/Api and the USI payload. There is no ecosystem to break, only one that was never built. |
| Streaming transport, flow control, cancellation | rpc Capture(stream CaptureRequest) returns (stream CaptureResponse) |
Already replaced. The live viewer's WebSocket does this today, and has since orbit-live-server landed. |
Set against that: dropping gRPC removes protobuf 36, gRPC 1.83, c-ares, and the entire BoringSSL-versus-OpenSSL conflict that took three attempts to resolve during the Bazel port. That is a larger win than the wire-format performance, and it is certain rather than projected.
Five things to fix in LiveEvent first
If this envelope grows to carry the whole capture stream, its cracks stop being cosmetic. All five are cheap to fix now and expensive to fix later.
kind, and one of them is named as though it means nothing at all. That is survivable at six event kinds and not at twelve.- Rename
_pad. It is a field, not padding, and its current name is an invitation to zero it during a refactor. - Give each kind a typed payload. Keep the 32-byte envelope, but define per-kind
#[repr(C)]payload structs viewed from the tail rather than reinterpretingduration_nsby convention.bytemuckorzerocopygiveFromBytes/AsBytesderives with compile-time layout checks, so nounsafeappears in Orbit's own code. - Stop encoding field by field.
encode_framecurrently runspayload.extend_from_slice(&ev.as_bytes())per event, andas_bytesdoes eight separateto_le_bytescalls. Every target Orbit ships is little-endian: assert it with a#[cfg(target_endian)]compile error andcast_slicethe whole batch in oneextend_from_slice. On the wasm side it becomes a typed-array view with no copy at all. - Cap
FunctionCall's registers inline.repeated uint64 registersis the one genuinely variable field in the hot lane. It holds the SysV argument registers, so six inline slots covers it — no side table needed. - Let a batch identify itself.
Statuscarries counters, but anEventBatchhas no sequence number or base timestamp, so a client that reconnects cannot tell what it missed. Oneu64fixes it.
LiveEvent derives Serialize and Deserialize. If the wire is raw bytes and the spill file is raw bytes, serde is being carried for the dev path alone — worth confirming it still earns its dependency.
Sequencing
Land the three-lane split in the live protocol first. It is first-party, already versioned, and has no external consumers — the cheapest possible place to be wrong. gRPC stays as the legacy transport for the Qt client until the Qt client goes away, which is the same gate as everything else in this document and for the same reason.
The .orbit container gets the same three lanes, and the existing protobuf reader becomes an import path for old captures. That is bounded work with a defined end, not a second format to maintain.
What this changes about the rest of the analysis is the framing. The project stops being "port the C++ service to Rust" — with its FFI seam, its two protobuf universes, and its per-event marshalling cost — and becomes "define the protocol, then write a collector that speaks it." The C++ service keeps working as one producer against that protocol for exactly as long as it needs to, and nothing has to be ported in place.
What would make this wrong
Three things would change the ranking, and it is worth naming them so they can be watched for rather than argued about.
If the Qt client is retired sooner than expected, the Tier 1 gate disappears and ObjectUtils, CaptureFile and GrpcProtos stop having C++ consumers at all. That would move the whole sequence forward by however long the client survives, and would also make the "two protobuf universes" objection evaporate — there would only be one, and it would be prost's.
If the wire format lands first, the tier list above stops being the plan and becomes a fallback. That case is made in its own section because it turned out to be the stronger argument: a protocol boundary costs no FFI, forces no shared types, and lets the C++ service keep running unmodified while a Rust collector grows beside it. The risk is the reverse of a port's — not that the migration stalls half-done, but that two collectors both work and neither is retired.
If a capture-correctness bug traces to a memory error in the C++ parsers, Tier 1 becomes Tier 0. That is the one class of failure where this port stops being an engineering preference and starts being a fix.
Before committing to Tier 2, capture the same workload twice — once through the existing pipeline, once through a Rust prototype of just the ring-buffer reader and decoder — and compare event counts, lost-event counts, and timestamps. If the Rust reader cannot keep up with the same ring buffer sizes, the whole Tier 2 argument changes shape, and that is a two-week experiment rather than a two-year commitment.
In one paragraph
Porting OrbitService to Rust is worth doing, is already a third started, and is a bad idea as a project. The parts of the service where Rust wins decisively — parsing binaries that the user's toolchain produced and the kernel's variable-length records — are also the parts that are smallest, most isolated, and easiest to test, so they should go first and they will pay for themselves. The parts that would be a heroic rewrite are the unwinder and the trampoline writer, neither of which becomes safer in Rust, one of which changes the profiler's output. And the code that runs inside the program being profiled should stay 503 lines of C++ with no runtime for as long as Orbit exists. The correct project is not "port the service"; it is "settle the protocol, then keep growing the Rust half along the seams that are already narrow" — which, protocol aside, is what the last few months of commits have been doing anyway.